Compare commits

...
6 Commits
Author SHA1 Message Date
LocoAgent 614044a6ca merge: все правки из fix/audit-2026-08-05 в основную ветку (опросник 187-ФЗ, угрозы, показатели значимости, доп. ОКВЭД) 2026-08-05 13:28:45 +04:00
LocoAgent 9a1058559a показатели значимости: автогенерация таблицы 1-14 в шаблон категорирования (обоснование применимости по сфере + авторасчёт 8/9 из данных организации) 2026-08-05 12:44:03 +04:00
LocoAgent a8fa3f908c fix: угрозы — фильтр по словарю УБИ для всего от ИИ (мусор отбрасывается, УБИ.069 дополняется названием), «Подобрать угрозы» — окно с галочками (синий — программа, красный — ИИ, зелёный — совпало), «Проверить через ИИ» — вердикт + точечное применение по словарю, возвращена кнопка «Загрузить угрозы ФСТЭК» 2026-08-05 11:03:28 +04:00
LocoAgent 033c21fccc опросник: замена на Опросный_лист_187_ФЗ.xlsx (5.25, листы Общие сведения/Сотрудники/ОБЪЕКТ №1), удалён старый 152-ФЗ 2026-08-05 10:46:47 +04:00
LocoAgent bf75d67f5e fix: доп. ОКВЭД из DaData (устойчивый парсинг + лог), сравнение угроз (шире окно, сортировка по УБИ), кнопка «Проверить через ИИ» (check_threats_ai), честные подсказки п.8/п.9, автозаполнение ΔДоходов из балансовой стоимости КИИ, диалог _ask_amount вместо askfloat (формат «121 118 270 руб»), формулировка Бусредн по документу ФСТЭК (трёхлетний период) 2026-08-05 10:41:31 +04:00
LocoAgent 5af23ae525 fix: аудит dokogen187 — критические и высокие замечания
- api_settings: убраны реальные API-ключи из кода (env/файл) — 1.1
- verify: промт ИИ для проверки реквизитов больше не ссылается на 187-ФЗ
  как на закон о ПДн («юрист по ПДн (187-ФЗ)» → специалист по реквизитам)
- ui: дата ПП РФ №127 исправлена 08.02.2019 → 08.02.2018 (3 места)
- help_docs: группа B переименована «187-ФЗ (персональные данные)» →
  «187-ФЗ (критическая информационная инфраструктура)»
- generator: убран дубль хвоста параграфа в _highlight_unmatched
- importer: «магия дат» (числа 10001-79999 → дата Excel) теперь только
  для полей-дат (as_date), финансовые поля не ломаются
- ui (_collect_is): убраны дубли пар маппинга attacker_level/attacker_types
- ui (импорт): убрано наследие 152-ФЗ ispdnPosition/ispdnFio из маппинга
- test_generator: переписан под модель 187-ФЗ (category/sphere/object_type
  вместо pd_count/threat_type/defence_level)
- criteria_justifications: п.9 для здравоохранения согласован с кодом
  (убран «критерий неприменим» — противоречие с «применим ко всем»)
- criteria_applicability: Транспорт (добавлены п.1, п.2; убран п.10),
  Банковская сфера (добавлены п.8, п.9) — требуется экспертная сверка
- ui: Бусредн вынесен в константу DEFAULT_BUDGET_AVG (данные Минфина,
  обновлять ежегодно); автозаполнение п.9 унифицировано с авторасчётом
  (ΔБ = ΔНП+ΔНДПИ+ΔДИВ+ΔВСО+ΔНС)
- ui: п.8 — предупреждение при неполных данных (менее 5 лет доходов)
- models: поля ndpi/div/vso/ns для п.9; комментарий income_loss уточнён
- .gitignore: api_settings.json/.env не попадают в git
2026-08-05 09:16:16 +04:00
15 changed files with 795 additions and 149 deletions
+5
View File
@@ -3,3 +3,8 @@ __pycache__/
.autosave/ .autosave/
Сгенерированные_документы/ Сгенерированные_документы/
*.result.docx *.result.docx
# Секреты и локальные настройки — НЕ коммитить
api_settings.json
.env
.env.local
+10 -4
View File
@@ -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)
Binary file not shown.
@@ -23,9 +23,10 @@
"9" "9"
], ],
"Транспорт": [ "Транспорт": [
"10", "1",
"11", "2",
"3" "3",
"11"
], ],
"Связь": [ "Связь": [
"4" "4"
@@ -61,6 +62,8 @@
"14" "14"
], ],
"Банковская сфера и финансовый рынок": [ "Банковская сфера и финансовый рынок": [
"8",
"9",
"10" "10"
], ],
"Топливно-энергетический комплекс": [ "Топливно-энергетический комплекс": [
@@ -1,23 +1,23 @@
{ {
"Здравоохранение": { "Здравоохранение": {
"1": "Рассматриваемый объект КИИ не участвует в обработке информации, необходимой для оказания медицинской помощи, проведения медицинских экспертиз, медицинских осмотров и медицинских освидетельствований, санитарно-противоэпидемических (профилактических) мероприятий и не осуществляет управление, контроль или мониторинг данных бизнес-процессов. Объект является вспомогательной информационной системой, целью которой является автоматизация процессов ведения бухгалтерского учета. Объект не осуществляет мониторинг, контроль или управление другими объектами КИИ, обеспечивающими жизненно важные функции организма. Утрата или иное повреждение объекта КИИ не может повлиять на жизненно важные функции организма.", "1": "Рассматриваемый объект КИИ не участвует в обработке информации, необходимой для оказания медицинской помощи, проведения медицинских экспертиз, медицинских осмотров и медицинских освидетельствований, санитарно-противоэпидемических (профилактических) мероприятий и не осуществляет управление, контроль или мониторинг данных бизнес-процессов. Объект является вспомогательной информационной системой, целью которой является автоматизация процессов ведения бухгалтерского учета. Объект не осуществляет мониторинг, контроль или управление другими объектами КИИ, обеспечивающими жизненно важные функции организма. Утрата или иное повреждение объекта КИИ не может повлиять на жизненно важные функции организма.",
"2": "Объект КИИ не относится к объектам обеспечения жизнедеятельности населения, в том числе объектам водоснабжения и канализации, очистки сточных вод, тепло- и электроснабжения, гидротехнических сооружений.", "2": "Объект КИИ не относится к объектам обеспечения жизнедеятельности населения, в том числе объектам водоснабжения и канализации, очистки сточных вод, тепло- и электроснабжения, гидротехнических сооружений.",
"3": "Объекты КИИ организации сферы здравоохранения не относятся к объектам транспортной инфраструктуры, и субъект КИИ не имеет в своем составе подразделения транспортной инфраструктуры.", "3": "Объекты КИИ организации сферы здравоохранения не относятся к объектам транспортной инфраструктуры, и субъект КИИ не имеет в своем составе подразделения транспортной инфраструктуры.",
"4": "Субъект КИИ не является оператором связи, объекты КИИ организации сферы здравоохранения не относятся к объектам транспортной инфраструктуры.", "4": "Субъект КИИ не является оператором связи, объекты КИИ организации сферы здравоохранения не относятся к объектам транспортной инфраструктуры.",
"5": "Организация сферы здравоохранения является юридическим лицом, обеспечивающим эксплуатацию информационной системы, задействованной в процессе оказания государственной услуги, однако нарушение функционирования информационной системы (рассматриваемого объекта КИИ), не участвующей в обработке информации, необходимой для оказания государственной услуги, не влечет за собой нарушение и (или) прекращение доступа к государственной услуге. Рассматриваемый объект КИИ не участвует в обработке информации, необходимой для оказания государственной услуги.", "5": "Организация сферы здравоохранения является юридическим лицом, обеспечивающим эксплуатацию информационной системы, задействованной в процессе оказания государственной услуги, однако нарушение функционирования информационной системы (рассматриваемого объекта КИИ), не участвующей в обработке информации, необходимой для оказания государственной услуги, не влечет за собой нарушение и (или) прекращение доступа к государственной услуге. Рассматриваемый объект КИИ не участвует в обработке информации, необходимой для оказания государственной услуги.",
"6": "Организация сферы здравоохранения не является органом государственной власти, в связи с этим критерий неприменим.", "6": "Организация сферы здравоохранения не является органом государственной власти, в связи с этим критерий неприменим.",
"7": "Организация сферы здравоохранения не является субъектом политической сферы деятельности, в связи с этим критерий неприменим.", "7": "Организация сферы здравоохранения не является субъектом политической сферы деятельности, в связи с этим критерий неприменим.",
"8": "Организация сферы здравоохранения не является государственной корпорацией, государственным унитарным предприятием, государственной компанией, стратегическим акционерным обществом, стратегическим предприятием, в связи с этим критерий неприменим.", "8": "Организация сферы здравоохранения не является государственной корпорацией, государственным унитарным предприятием, государственной компанией, стратегическим акционерным обществом, стратегическим предприятием, в связи с этим критерий неприменим.",
"9": "Объект КИИ задействован только в предоставлении медицинских услуг, освобожденных от налогообложения, в связи с этим критерий неприменим.", "9": "Объект КИИ задействован в предоставлении медицинских услуг. Показатель 9 применим ко всем субъектам КИИ; при расчёте учитывается, что медицинские услуги освобождены от налогообложения (значение показателя может быть минимальным).",
"10": "Организация сферы здравоохранения не является субъектом банковской сферы деятельности.", "10": "Организация сферы здравоохранения не является субъектом банковской сферы деятельности.",
"10.1": "Организация сферы здравоохранения не является центральным контрагентом.", "10.1": "Организация сферы здравоохранения не является центральным контрагентом.",
"10.2": "Организация сферы здравоохранения не является центральным депозитарием.", "10.2": "Организация сферы здравоохранения не является центральным депозитарием.",
"10.3": "Организация сферы здравоохранения не является негосударственным пенсионным фондом.", "10.3": "Организация сферы здравоохранения не является негосударственным пенсионным фондом.",
"10.4": "Организация сферы здравоохранения не является страховой организацией.", "10.4": "Организация сферы здравоохранения не является страховой организацией.",
"10.5": "Организация сферы здравоохранения не является оператором услуг информационного обмена.", "10.5": "Организация сферы здравоохранения не является оператором услуг информационного обмена.",
"11": "Объекты КИИ организации сферы здравоохранения не относятся к опасным производственным объектам в соответствии с законодательством Российской Федерации.", "11": "Объекты КИИ организации сферы здравоохранения не относятся к опасным производственным объектам в соответствии с законодательством Российской Федерации.",
"12": "Организация сферы здравоохранения не выполняет функции пункта управления (ситуационного центра) государственного органа власти или государственной корпорации.", "12": "Организация сферы здравоохранения не выполняет функции пункта управления (ситуационного центра) государственного органа власти или государственной корпорации.",
"13": "Организация сферы здравоохранения не является субъектом обеспечения обороны страны, безопасности государства и правопорядка.", "13": "Организация сферы здравоохранения не является субъектом обеспечения обороны страны, безопасности государства и правопорядка.",
"14": "Организация сферы здравоохранения не имеет информационных систем, функционирующих в области обеспечения обороны страны, безопасности государства и правопорядка." "14": "Организация сферы здравоохранения не имеет информационных систем, функционирующих в области обеспечения обороны страны, безопасности государства и правопорядка."
} }
} }
Binary file not shown.
Binary file not shown.
-3
View File
@@ -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)
+1 -1
View File
@@ -83,7 +83,7 @@ def _generate_variables_reference(path):
_p(doc, 'Список переменных для шаблонов Word. Переменная подставляется в текст ' _p(doc, 'Список переменных для шаблонов Word. Переменная подставляется в текст '
'как есть: {{имя_переменной}}. Циклы: <!-- loop:имя --> ... <!-- loop_end -->.') 'как есть: {{имя_переменной}}. Циклы: <!-- loop:имя --> ... <!-- loop_end -->.')
groups = {'A': 'Общие переменные', 'B': '187-ФЗ (персональные данные)'} groups = {'A': 'Общие переменные', 'B': '187-ФЗ (критическая информационная инфраструктура)'}
by_group = {'A': [], 'B': []} by_group = {'A': [], 'B': []}
for var, group, desc, _ in VARIABLE_DEFS: for var, group, desc, _ in VARIABLE_DEFS:
by_group.setdefault(group, []).append((var, desc)) by_group.setdefault(group, []).append((var, desc))
+12 -5
View File
@@ -97,13 +97,18 @@ def normalize_address(text: str) -> str:
return ''.join(out) return ''.join(out)
def clean_value(val): def clean_value(val, as_date=False):
"""Очистка и нормализация значения ячейки Excel.""" """Очистка и нормализация значения ячейки Excel.
as_date=True конвертировать серийный номер Excel в дату (только для
полей-дат, напр. ogrnDate). Для финансовых полей НЕ вызывается,
иначе число 50 000 руб. превратится в «дату» (см. аудит importer.py:105-109).
"""
if val is None: if val is None:
return '' return ''
if isinstance(val, (int, float)): if isinstance(val, (int, float)):
# Попытка распознать дату (серийный номер Excel) # Попытка распознать дату (серийный номер Excel) — только для полей-дат
if 10000 < val < 80000: if as_date and 10000 < val < 80000:
serial = int(val) serial = int(val)
if serial > 60: if serial > 60:
serial -= 1 serial -= 1
@@ -253,7 +258,7 @@ def _import_187_common(wb, data, settings, log_fn):
if sn.lower() == sheet_part.lower(): if sn.lower() == sheet_part.lower():
target_ws = wb[sn] target_ws = wb[sn]
break break
val = clean_value(cell_value(target_ws, coord)) val = clean_value(cell_value(target_ws, coord), as_date=('дата' in name.lower() or 'date' in key.lower()))
if val: if val:
if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'): if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'):
val = _extract_single_fio(val) val = _extract_single_fio(val)
@@ -273,6 +278,8 @@ def _import_187_common(wb, data, settings, log_fn):
if val: if val:
if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'): if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'):
val = _extract_single_fio(val) val = _extract_single_fio(val)
elif 'дата' in name.lower() or 'date' in key.lower():
val = clean_value(val, as_date=True)
data[key] = val data[key] = val
log_fn(f" {name} [по ключу]: {val}") log_fn(f" {name} [по ключу]: {val}")
break break
+7 -1
View File
@@ -135,9 +135,15 @@ class Company:
income_year_3: str = "" # Доход за 3 года назад (руб.) income_year_3: str = "" # Доход за 3 года назад (руб.)
income_year_4: str = "" # Доход за 2 года назад (руб.) income_year_4: str = "" # Доход за 2 года назад (руб.)
income_year_5: str = "" # Доход за прошлый год (руб.) income_year_5: str = "" # Доход за прошлый год (руб.)
income_loss: str = "" # Суммарный экономический ущерб от нарушения income_loss: str = "" # Снижение уровня дохода (суммарный ущерб) от нарушения
# критических процессов (руб.) — ΔДоходов для п. 8 # критических процессов (руб.) — ΔДоходов для п. 8
# Показатель 9: компоненты ΔБ (унификация автозаполнения и авторасчёта)
ndpi: str = "" # Снижение выплат от налога на добычу ПИ ΔНДПИ (руб.)
div: str = "" # Снижение выплат от дивидендов ΔДИВ (руб.)
vso: str = "" # Снижение выплат сторонним организациям ΔВСО (руб.)
ns: str = "" # Снижение сборов ΔНС (руб.)
# Списки # Списки
commission: List[CommissionMember] = field(default_factory=list) commission: List[CommissionMember] = field(default_factory=list)
information_systems: List[InformationSystem] = field(default_factory=list) information_systems: List[InformationSystem] = field(default_factory=list)
+415 -99
View File
@@ -26,6 +26,11 @@ from .verify import (
) )
from .api_settings import load_api_settings, save_api_settings from .api_settings import load_api_settings, save_api_settings
# Бусредн — средний доход федерального бюджета за 3 года (данные Минфина).
# ВАЖНО: значение устаревает — обновлять ежегодно по данным Минфина или
# запрашивать у оператора в диалоге авторасчёта (поле редактируется).
DEFAULT_BUDGET_AVG = 21228053357700.0
class DokoGenApp: class DokoGenApp:
"""Главное окно приложения.""" """Главное окно приложения."""
@@ -353,7 +358,7 @@ class DokoGenApp:
("Доход за 3 года назад (руб.):", "income_year_3"), ("Доход за 3 года назад (руб.):", "income_year_3"),
("Доход за 2 года назад (руб.):", "income_year_4"), ("Доход за 2 года назад (руб.):", "income_year_4"),
("Доход за прошлый год (руб.):", "income_year_5"), ("Доход за прошлый год (руб.):", "income_year_5"),
("Суммарный экономический ущерб от нарушения критических процессов (руб.):", "income_loss"), ("Снижение уровня дохода от нарушения критических процессов (руб.):", "income_loss"),
] ]
for j, (label, key) in enumerate(income_fields): for j, (label, key) in enumerate(income_fields):
ttk.Label(f_fin, text=label).grid(row=len(fin_fields) + 1 + j, column=0, sticky=tk.W, pady=2) ttk.Label(f_fin, text=label).grid(row=len(fin_fields) + 1 + j, column=0, sticky=tk.W, pady=2)
@@ -756,9 +761,12 @@ class DokoGenApp:
self._company_entries['okved'].insert(0, resolve_okved(comp['okved'], _tok3)) self._company_entries['okved'].insert(0, resolve_okved(comp['okved'], _tok3))
if want('okved_extra') and comp.get('okved_extra') and hasattr(self, 'okved_extra_listbox'): if want('okved_extra') and comp.get('okved_extra') and hasattr(self, 'okved_extra_listbox'):
self.okved_extra_listbox.delete(0, tk.END) self.okved_extra_listbox.delete(0, tk.END)
_applied = 0
for code in comp['okved_extra']: for code in comp['okved_extra']:
if str(code).strip(): if str(code).strip():
self.okved_extra_listbox.insert(tk.END, resolve_okved(str(code).strip(), _tok3)) self.okved_extra_listbox.insert(tk.END, resolve_okved(str(code).strip(), _tok3))
_applied += 1
self.log(f"✅ Доп. ОКВЭД из DaData: применено {_applied} (всего в ответе: {len(comp['okved_extra'])})")
if want('chief'): if want('chief'):
if comp.get('chief_position') and 'chief_position' in self._official_entries: if comp.get('chief_position') and 'chief_position' in self._official_entries:
self._official_entries['chief_position'].delete(0, tk.END) self._official_entries['chief_position'].delete(0, tk.END)
@@ -1082,8 +1090,12 @@ class DokoGenApp:
command=self._suggest_threats_program_click).pack(side=tk.LEFT, padx=2) command=self._suggest_threats_program_click).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="🤖 Подобрать через ИИ", ttk.Button(thr_actions, text="🤖 Подобрать через ИИ",
command=self._suggest_threats_ai_click).pack(side=tk.LEFT, padx=2) command=self._suggest_threats_ai_click).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="⚖️ Сравнить", ttk.Button(thr_actions, text="🤖 Проверить через ИИ",
command=self._compare_threats).pack(side=tk.LEFT, padx=2) command=self._check_threats_ai_click).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="🎯 Подобрать угрозы (программа+ИИ)",
command=self._open_threats_picker).pack(side=tk.LEFT, padx=2)
ttk.Button(thr_actions, text="⬇️ Загрузить угрозы ФСТЭК",
command=self._download_fstec_threats).pack(side=tk.LEFT, padx=2)
self._threats_date_label = ttk.Label(thr_actions, text="", foreground='gray') self._threats_date_label = ttk.Label(thr_actions, text="", foreground='gray')
self._threats_date_label.pack(side=tk.LEFT, padx=8) self._threats_date_label.pack(side=tk.LEFT, padx=8)
@@ -1418,8 +1430,6 @@ class DokoGenApp:
('is_os_sw_listbox', 'os_sw'), ('is_os_sw_listbox', 'os_sw'),
('is_app_sw_listbox', 'app_sw'), ('is_app_sw_listbox', 'app_sw'),
('is_attacker_category_combo', 'attacker_category'), ('is_attacker_category_combo', 'attacker_category'),
('is_attacker_level_combo', 'attacker_level'),
('is_attacker_types_listbox', 'attacker_types'),
('is_attacker_level_combo', 'attacker_level'), ('is_attacker_level_combo', 'attacker_level'),
('is_attacker_types_listbox', 'attacker_types'), ('is_attacker_types_listbox', 'attacker_types'),
('is_threats_listbox', 'threats'), ('is_threats_listbox', 'threats'),
@@ -2079,7 +2089,7 @@ class DokoGenApp:
def _open_criteria_127_dialog(self): def _open_criteria_127_dialog(self):
"""Окно заполнения показателей критериев значимости по ПП РФ № 127. """Окно заполнения показателей критериев значимости по ПП РФ № 127.
Таблица перенесена дословно из Постановления Правительства РФ от 08.02.2019 127: Таблица перенесена дословно из Постановления Правительства РФ от 08.02.2018 127:
5 критериев (социальная, политическая, экономическая, экологическая, 5 критериев (социальная, политическая, экономическая, экологическая,
значимость для обороны страны), показатели 1-14 с подпунктами, значимость для обороны страны), показатели 1-14 с подпунктами,
значения сразу привязаны к категориям III / II / I. значения сразу привязаны к категориям III / II / I.
@@ -2169,7 +2179,7 @@ class DokoGenApp:
ttk.Label(body, text="Показатели критериев значимости объекта КИИ", ttk.Label(body, text="Показатели критериев значимости объекта КИИ",
font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 2)) font=('Arial', 11, 'bold')).pack(anchor=tk.W, pady=(0, 2))
ttk.Label(body, text="Постановление Правительства РФ от 08.02.2019 № 127 «Об утверждении Правил категорирования " ttk.Label(body, text="Постановление Правительства РФ от 08.02.2018 № 127 «Об утверждении Правил категорирования "
"объектов критической информационной инфраструктуры РФ» (приложение). " "объектов критической информационной инфраструктуры РФ» (приложение). "
"По каждому показателю выберите значение, которому соответствует объект. " "По каждому показателю выберите значение, которому соответствует объект. "
"Категория определяется по наивысшей из выбранных.", "Категория определяется по наивысшей из выбранных.",
@@ -2242,14 +2252,24 @@ class DokoGenApp:
e_profit = _row("Прибыль субъекта КИИ за прошедший год (руб.):", 'profit', profit) e_profit = _row("Прибыль субъекта КИИ за прошедший год (руб.):", 'profit', profit)
e_tax = _row("Налог на прибыль за прошедший год (руб.):", 'tax', profit_tax) e_tax = _row("Налог на прибыль за прошедший год (руб.):", 'tax', profit_tax)
e_dincome = _row("Снижение уровня доходов ΔДоходов (руб., наихудший сценарий):", 'dincome', '') # ΔДоходов: если есть балансовая стоимость объекта КИИ — предзаполняем её
ttk.Label(frm, text="⚠️ ΔДоходов — экспертная оценка снижения доходов субъекта при наихудшем сценарии " _bv9 = getattr(isys, 'balance_value', '') or ''
"нарушения (в опроснике её нет, вводится вручную; можно приравнять к ущербу из показателя 8).", _bvn9 = _num(_bv9)
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) e_dincome = _row("Снижение уровня доходов ΔДоходов (руб., наихудший сценарий):", 'dincome',
e_budget = _row("Доход федерального бюджета, усреднённый за 3 года Бусредн (руб.):", 'budget', f"{_bvn9:,.0f}" if _bvn9 else '')
'21228053357700') if _bvn9:
ttk.Label(frm, text="⚠️ Бусредн — средний доход федерального бюджета за 3 года (данные Минфина, " ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ "
"подставлено значение по умолчанию; при необходимости уточните).", f"({_bvn9:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
else:
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
"балансовую стоимость объекта КИИ во вкладке «Объект».",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
e_budget = _row("Прогнозируемый годовой доход федерального бюджета, усреднённый за планируемый трёхлетний период — Бусредн (руб.):", 'budget',
f'{DEFAULT_BUDGET_AVG:.0f}')
ttk.Label(frm, text="⚠️ Бусредн — прогнозируемый годовой доход федерального бюджета, "
"усреднённый за планируемый трёхлетний период (данные Минфина; "
"подставлено значение по умолчанию, при необходимости уточните).",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
ttk.Label(frm, text="Прочие составляющие ΔБ (если применимы, иначе 0):", ttk.Label(frm, text="Прочие составляющие ΔБ (если применимы, иначе 0):",
font=('', 9, 'bold')).pack(anchor=tk.W, pady=(8, 0)) font=('', 9, 'bold')).pack(anchor=tk.W, pady=(8, 0))
@@ -2275,19 +2295,34 @@ class DokoGenApp:
e_profit = e_tax = None e_profit = e_tax = None
# Доходы за 5 лет — из данных организации (поля «Доходы за 5 лет») # Доходы за 5 лет — из данных организации (поля «Доходы за 5 лет»)
_incs = [_num(getattr(self.model, f'income_year_{i}', '') or '') for i in range(1, 6)] _incs_raw = [getattr(self.model, f'income_year_{i}', '') or '' for i in range(1, 6)]
_incs = [x for x in _incs if x is not None] _incs = [_num(x) for x in _incs_raw]
_d_avg = (sum(_incs) / len(_incs)) if _incs else None _incs_filled = [x for x in _incs if x is not None]
_d_avg = (sum(_incs_filled) / len(_incs_filled)) if _incs_filled else None
_d_loss = _num(getattr(self.model, 'income_loss', '') or '') _d_loss = _num(getattr(self.model, 'income_loss', '') or '')
e_dincome = _row("Суммарный экономический ущерб от нарушения критических процессов (руб.):", _bv8 = getattr(isys, 'balance_value', '') or ''
'dincome', f"{_d_loss:,.0f}" if _d_loss else '') _bvn8 = _num(_bv8)
ttk.Label(frm, text="⚠️ Ущерб — поле «Суммарный ущерб (показатель 8)» из вкладки «Организация» " _dincome_def = _d_loss or _bvn8
"(подставлено автоматически, можно поправить).", e_dincome = _row("Снижение уровня дохода от нарушения критических процессов (руб.):",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) 'dincome', f"{_dincome_def:,.0f}" if _dincome_def else '')
if _d_loss:
ttk.Label(frm, text="⚠️ ΔДоходов — подставлено из вкладки «Организация» "
f"({_d_loss:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
elif _bvn8:
ttk.Label(frm, text="⚠️ ΔДоходов — подставлена балансовая стоимость объекта КИИ "
f"({_bvn8:,.0f} руб.); можно поправить.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
else:
ttk.Label(frm, text="⚠️ ΔДоходов — не заполнено: введите вручную или заполните "
"«Снижение уровня дохода» во вкладке «Организация».",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
e_budget = _row("Доходы субъекта КИИ, усреднённые за 5 лет (руб.):", e_budget = _row("Доходы субъекта КИИ, усреднённые за 5 лет (руб.):",
'budget', f"{_d_avg:,.0f}" if _d_avg else '') 'budget', f"{_d_avg:,.0f}" if _d_avg else '')
ttk.Label(frm, text="⚠️ Доходы за 5 лет — среднее по полям «Доход за …» из вкладки «Организация» " _n_years = len(_incs_filled)
"(подставлено автоматически, можно поправить).", ttk.Label(frm, text=f"⚠️ Доходы за 5 лет — среднее по заполненным годам ({_n_years} из 5). "
"ПП-127 требует усреднение за прошедший пятилетний период: "
"при неполных данных результат методологически некорректен — заполните все 5 лет.",
foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W) foreground='#8a6d00', wraplength=900, justify=tk.LEFT).pack(anchor=tk.W)
result = tk.Text(frm, height=8, wrap=tk.WORD, state=tk.NORMAL) result = tk.Text(frm, height=8, wrap=tk.WORD, state=tk.NORMAL)
result.pack(fill=tk.BOTH, expand=True, pady=(8, 0)) result.pack(fill=tk.BOTH, expand=True, pady=(8, 0))
@@ -2302,7 +2337,7 @@ class DokoGenApp:
result.insert('1.0', "Заполните оба поля (ущерб и доходы за 5 лет).") result.insert('1.0', "Заполните оба поля (ущерб и доходы за 5 лет).")
return return
val = d / b * 100 val = d / b * 100
lines.append(f"Суммарный экономический ущерб от нарушения критических процессов: {d:,.0f} руб.") lines.append(f"Снижение уровня дохода от нарушения критических процессов: {d:,.0f} руб.")
lines.append(f"Доходы субъекта КИИ, усреднённые за 5 лет: {b:,.0f} руб.") lines.append(f"Доходы субъекта КИИ, усреднённые за 5 лет: {b:,.0f} руб.")
lines.append(f"№8 = ущерб / доходы за 5 лет × 100% = {d:,.0f} / {b:,.0f} × 100 = {val:.2f} %") lines.append(f"№8 = ущерб / доходы за 5 лет × 100% = {d:,.0f} / {b:,.0f} × 100 = {val:.2f} %")
if val > 20: if val > 20:
@@ -2344,7 +2379,7 @@ class DokoGenApp:
db = dnp + ndpi + div + vso + ns db = dnp + ndpi + div + vso + ns
lines.append(f"ΔНДПИ = {ndpi:,.0f} руб., ΔДИВ = {div:,.0f} руб., ΔВСО = {vso:,.0f} руб., ΔНС = {ns:,.0f} руб.") lines.append(f"ΔНДПИ = {ndpi:,.0f} руб., ΔДИВ = {div:,.0f} руб., ΔВСО = {vso:,.0f} руб., ΔНС = {ns:,.0f} руб.")
lines.append(f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.") lines.append(f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.")
lines.append(f"Бусредн (доход федерального бюджета за 3 года): {b:,.0f} руб.") lines.append(f"Бусредн (прогноз годового дохода федбюджета, усреднённый за планируемый трёхлетний период): {b:,.0f} руб.")
val = db / b * 100 val = db / b * 100
lines.append(f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {b:,.0f} × 100 = {val:.6f} %") lines.append(f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {b:,.0f} × 100 = {val:.6f} %")
if val > 0.001: if val > 0.001:
@@ -2585,7 +2620,7 @@ class DokoGenApp:
if chosen: if chosen:
auto_parts.append( auto_parts.append(
"Категория значимости объекта КИИ определена в соответствии с Правилами категорирования " "Категория значимости объекта КИИ определена в соответствии с Правилами категорирования "
"объектов критической информационной инфраструктуры РФ (ПП РФ от 08.02.2019 № 127). " "объектов критической информационной инфраструктуры РФ (ПП РФ от 08.02.2018 № 127). "
f"Объект отнесён к {cat} категории значимости. Основания: " f"Объект отнесён к {cat} категории значимости. Основания: "
+ "; ".join(f"показатель {num} ({c} категория)" for num, _, c, _ in chosen) + "." + "; ".join(f"показатель {num} ({c} категория)" for num, _, c, _ in chosen) + "."
) )
@@ -2672,25 +2707,35 @@ class DokoGenApp:
profit = _num2(getattr(self.model, 'profit', '') or '') profit = _num2(getattr(self.model, 'profit', '') or '')
tax = _num2(getattr(self.model, 'profit_tax', '') or '') tax = _num2(getattr(self.model, 'profit_tax', '') or '')
if profit and tax: if profit and tax:
# Подсказка: балансовая стоимость объекта как ориентир для ΔДоходов # ΔДоходов: если есть балансовая стоимость объекта КИИ —
_hint = '' # подставляем её автоматически (наихудший сценарий = объект
# выходит из строя, его надо восстанавливать/покупать заново)
_bv = getattr(isys, 'balance_value', '') or '' _bv = getattr(isys, 'balance_value', '') or ''
_bvn = _num2(_bv) _bvn = _num2(_bv)
if _bvn: if _bvn:
_hint = f"\n(балансовая стоимость объекта: {_bvn:,.0f} руб. — можно использовать как ориентир)" dincome = _bvn
dincome = simpledialog.askfloat( _dincome_note = (f"ΔДоходов = балансовая стоимость объекта КИИ "
"Показатель 9 — ущерб бюджетам", f"({dincome:,.0f} руб.) — подставлено автоматически")
"Снижение уровня доходов субъекта КИИ ΔДоходов (руб.):\n" else:
"(наихудший сценарий компьютерной атаки на объект)" + _hint, dincome = self._ask_amount(
parent=dialog, minvalue=0) dialog, "Показатель 9 — ущерб бюджетам",
if dincome is None: "Снижение уровня доходов субъекта КИИ ΔДоходов (руб.):\n"
continue "(наихудший сценарий компьютерной атаки на объект)")
budget = 21228053357700.0 # Бусредн (доход федерального бюджета за 3 года) if dincome is None:
continue
_dincome_note = f"ΔДоходов (введено оператором) = {dincome:,.0f} руб."
budget = DEFAULT_BUDGET_AVG # Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года)
knp = tax / profit knp = tax / profit
dnp = dincome * knp dnp = dincome * knp
if dincome > profit: if dincome > profit:
dnp = tax dnp = tax
val = dnp / budget * 100 # Унификация с авторасчётом: ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС
ndpi = _num2(getattr(self.model, 'ndpi', '') or '') or 0
div = _num2(getattr(self.model, 'div', '') or '') or 0
vso = _num2(getattr(self.model, 'vso', '') or '') or 0
ns = _num2(getattr(self.model, 'ns', '') or '') or 0
db = dnp + ndpi + div + vso + ns
val = db / budget * 100
if val > 0.001: if val > 0.001:
cat = 'I' cat = 'I'
elif val > 0.0006: elif val > 0.0006:
@@ -2706,10 +2751,13 @@ class DokoGenApp:
if target: if target:
var.set(target) var.set(target)
calc_txt = ( calc_txt = (
f"{_dincome_note}\n"
f"Коэффициент КНП = налог/прибыль = {tax:,.0f} / {profit:,.0f} = {knp:.4f}\n" f"Коэффициент КНП = налог/прибыль = {tax:,.0f} / {profit:,.0f} = {knp:.4f}\n"
f"ΔНП = ΔДоходов × КНП = {dincome:,.0f} × {knp:.4f} = {dnp:,.1f} руб.\n" f"ΔНП = ΔДоходов × КНП = {dincome:,.0f} × {knp:.4f} = {dnp:,.1f} руб.\n"
f"Бусредн (доход федерального бюджета за 3 года) = {budget:,.0f} руб.\n" f"ΔНДПИ = {ndpi:,.0f}, ΔДИВ = {div:,.0f}, ΔВСО = {vso:,.0f}, ΔНС = {ns:,.0f} руб.\n"
f"№9 = ΔБ / Бусредн × 100% = {dnp:,.1f} / {budget:,.0f} × 100 = {val:.6f} %\n" f"ΔБ = ΔНП + ΔНДПИ + ΔДИВ + ΔВСО + ΔНС = {db:,.1f} руб.\n"
f"Бусредн (прогноз годового дохода федбюджета, усреднённый за 3 года) = {budget:,.0f} руб.\n"
f"№9 = ΔБ / Бусредн × 100% = {db:,.1f} / {budget:,.0f} × 100 = {val:.6f} %\n"
f"Категория значимости: {cat or '— (не присваивается)'}" f"Категория значимости: {cat or '— (не присваивается)'}"
) )
prefix = f"Показатель {num}. {name} — рекомендация по расчёту:" prefix = f"Показатель {num}. {name} — рекомендация по расчёту:"
@@ -2923,31 +2971,87 @@ class DokoGenApp:
if not threats: if not threats:
self._verify_log("❌ ИИ не вернул угрозы") self._verify_log("❌ ИИ не вернул угрозы")
return return
lb = self.is_threats_listbox # Запоминаем рекомендации ИИ (уже отфильтрованы по словарю УБИ) —
lb.delete(0, tk.END) # не заменяем список молча, а открываем окно подбора с галочками.
for t in threats: self._last_ai_threats = list(threats)
lb.insert(tk.END, t) self.log(f"🤖 ИИ рекомендует угроз: {len(threats)}")
self._collect_is() # сохранить в модель — галочки в словаре будут стоять self._verify_log(f"🤖 ИИ: {len(threats)} угроз (отфильтровано по словарю УБИ)")
self.log(f"🤖 ИИ подобрал угроз: {len(threats)}") self._open_threats_picker()
self._verify_log(f"🤖 ИИ: {len(threats)} угроз")
def _compare_threats(self): def _ask_amount(self, parent, title, prompt, initial=''):
"""Сравнить угрозы, подобранные программой и ИИ.""" """Свой диалог ввода суммы: принимает «121 118 270 руб», «1 000» и т.п.
simpledialog.askfloat НЕ умеет парсить такие строки (ошибка
«not a floating-point value please try again») поэтому свой диалог.
Возвращает float или None (отмена).
"""
def _parse(s):
s = str(s or '').strip()
if not s:
return None
s = s.replace('\u00a0', ' ').replace(' ', '')\
.replace('руб', '').replace('', '').replace('р.', '').replace(',', '.')
try:
return float(s)
except Exception:
return None
result = {'val': None}
dlg = tk.Toplevel(parent)
dlg.title(title)
dlg.geometry("560x200")
dlg.transient(parent)
dlg.grab_set()
ttk.Label(dlg, text=prompt, wraplength=520, justify=tk.LEFT).pack(
padx=12, pady=(12, 6), anchor=tk.W)
e = ttk.Entry(dlg, width=42)
e.insert(0, str(initial or ''))
e.pack(padx=12, pady=4, fill=tk.X)
err = ttk.Label(dlg, text="", foreground='red')
err.pack(anchor=tk.W, padx=12)
def _ok():
v = _parse(e.get())
if v is None:
err.config(text="Введите число, например: 121 118 270")
return
result['val'] = v
dlg.grab_release()
dlg.destroy()
def _cancel():
dlg.grab_release()
dlg.destroy()
btns = ttk.Frame(dlg)
btns.pack(pady=8)
ttk.Button(btns, text="ОК", command=_ok).pack(side=tk.LEFT, padx=4)
ttk.Button(btns, text="Отмена", command=_cancel).pack(side=tk.LEFT, padx=4)
e.bind('<Return>', lambda _ev: _ok())
dlg.lift()
dlg.focus_force()
parent.wait_window(dlg)
return result['val']
def _open_threats_picker(self):
"""🎯 Окно подбора угроз: программа (синий) + ИИ (красный) + совпавшие (зелёный).
Оператор отмечает галочками нужные угрозы (как в словаре) и жмёт
« Применить выбранные» список угроз заменяется выбранными.
По умолчанию отмечены все подобранные (программа + ИИ + текущие),
оператор снимает галочки с неугодных.
"""
if self._current_is_idx is None: if self._current_is_idx is None:
messagebox.showinfo("Сравнение", "Сначала выберите объект КИИ в списке слева") messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
return return
self._collect_is() self._collect_is()
isys = self.model.information_systems[self._current_is_idx] isys = self.model.information_systems[self._current_is_idx]
from .threat_rules import suggest_threats_program from .threat_rules import suggest_threats_program
program = suggest_threats_program(isys) program = suggest_threats_program(isys)
current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())] current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())]
if not current: ai = list(getattr(self, '_last_ai_threats', None) or [])
messagebox.showinfo("Сравнение", "Сначала подберите угрозы (программой или ИИ).")
return
# Нормализация строк угроз для сравнения: # Нормализация строк угроз: ключ сравнения — код УБИ (если есть),
# «1. УБИ.001 Угроза…», «УБИ.001 Угроза…» и «УБИ.001 Угроза…» — это одна и та же угроза.
# Ключ сравнения — код УБИ, если он есть (ИИ может обрезать/перефразировать название);
# иначе — нормализованный текст. # иначе — нормализованный текст.
def _ubi_code(s): def _ubi_code(s):
m = re.search(r'уби\.?\s*(\d+)', str(s).lower()) m = re.search(r'уби\.?\s*(\d+)', str(s).lower())
@@ -2962,49 +3066,261 @@ class DokoGenApp:
code = _ubi_code(s) code = _ubi_code(s)
return ('ubi', code) if code else ('name', _norm(s)) return ('ubi', code) if code else ('name', _norm(s))
pset_norm = {_key(t) for t in program} # Объединяем все источники: key → {text, src: {program, ai, current}}
cset_norm = {_key(t) for t in current} merged = {}
def _add(t, src):
# Показываем окно: слева подобрано программой, справа текущий список if not str(t or '').strip():
dlg = tk.Toplevel(self.root) return
dlg.title("Сравнение угроз: программа ↔ ИИ/текущий") k = _key(t)
dlg.geometry("960x600") if k not in merged:
dlg.transient(self.root) merged[k] = {'text': str(t).strip(), 'src': set()}
merged[k]['src'].add(src)
frm = ttk.Frame(dlg, padding=8)
frm.pack(fill=tk.BOTH, expand=True)
left = ttk.Frame(frm)
left.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
ttk.Label(left, text=f"⚙️ Программа ({len(program)})", font=('Arial', 9, 'bold')).pack(anchor=tk.W)
lb_p = tk.Listbox(left, height=18)
lb_p.pack(fill=tk.BOTH, expand=True, pady=4)
for t in program: for t in program:
lb_p.insert(tk.END, t) _add(t, 'program')
right = ttk.Frame(frm)
right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0))
ttk.Label(right, text=f"🤖 ИИ / текущий ({len(current)})", font=('Arial', 9, 'bold')).pack(anchor=tk.W)
lb_c = tk.Listbox(right, height=18)
lb_c.pack(fill=tk.BOTH, expand=True, pady=4)
for t in current: for t in current:
lb_c.insert(tk.END, t) _add(t, 'current')
for t in ai:
_add(t, 'ai')
# Подсветка совпадений (по коду УБИ / нормализованному виду) items = sorted(merged.values(),
common = pset_norm & cset_norm key=lambda v: (0, int(_ubi_code(v['text'])))
for i in range(lb_p.size()): if _ubi_code(v['text']) else (1, _norm(v['text'])))
if _key(lb_p.get(i)) in common:
lb_p.itemconfig(i, bg='#c8e6c9')
for i in range(lb_c.size()):
if _key(lb_c.get(i)) in common:
lb_c.itemconfig(i, bg='#c8e6c9')
info = ttk.Label(frm, # Окно с чекбоксами (как выбор из словаря)
text=f"Совпало угроз: {len(common)} (зелёным). " dlg = tk.Toplevel(self.root)
f"Только программа: {len(program) - len(common)}, " dlg.title(f"Подбор угроз: {isys.name or ''}")
f"только ИИ/текущий: {len(current) - len(common)}.", dlg.geometry("980x700")
foreground='gray') dlg.transient(self.root)
info.pack(anchor=tk.W, pady=(4, 0)) dlg.grab_set()
frame = ttk.Frame(dlg, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
legend = ttk.Label(frame,
text="🟦 синий — рекомендация программы | 🟥 красный — рекомендация ИИ | "
"🟩 зелёный — совпало | ⬜ белый — уже было в списке",
foreground='gray')
legend.pack(anchor=tk.W, pady=(0, 5))
# Поиск
search_var = tk.StringVar()
search_entry = ttk.Entry(frame, textvariable=search_var)
search_entry.pack(fill=tk.X, pady=(0, 5))
search_entry.focus_set()
canvas = tk.Canvas(frame, highlightthickness=0)
scrollbar = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=canvas.yview)
check_frame = ttk.Frame(canvas)
check_frame.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=check_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
vars = {}
widgets = {}
for v in items:
src = v['src']
has_p = 'program' in src
has_a = 'ai' in src
if has_p and has_a:
bg = '#c8e6c9' # зелёный — совпало
elif has_p:
bg = '#b3d4fc' # синий — только программа
elif has_a:
bg = '#ffd0d0' # красный — только ИИ
else:
bg = '#ffffff' # белый — только текущий список
var = tk.BooleanVar(value=True) # по умолчанию отмечено всё
cb = tk.Checkbutton(check_frame, text=v['text'], variable=var, anchor=tk.W,
bg=bg, activebackground=bg, selectcolor='white',
justify=tk.LEFT, wraplength=900)
cb.pack(fill=tk.X, pady=1)
vars[v['text']] = var
widgets[v['text']] = cb
def _apply_search(*_):
q = search_var.get().strip().lower()
for text, cb in widgets.items():
if q and q not in text.lower():
cb.pack_forget()
else:
cb.pack(fill=tk.X, pady=1)
search_var.trace_add('write', _apply_search)
btn_frame = ttk.Frame(dlg, padding=10)
btn_frame.pack(fill=tk.X)
def _select_all():
for var in vars.values():
var.set(True)
def _clear_all():
for var in vars.values():
var.set(False)
def _apply():
selected = [t for t, var in vars.items() if var.get()]
selected.sort(key=lambda s: (0, int(_ubi_code(s)))
if _ubi_code(s) else (1, _norm(s)))
lb = self.is_threats_listbox
lb.delete(0, tk.END)
for t in selected:
lb.insert(tk.END, t)
self._collect_is()
self.log(f"🎯 Применено угроз: {len(selected)}")
dlg.destroy()
ttk.Button(btn_frame, text="Выбрать всё", command=_select_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="Снять всё", command=_clear_all).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="✅ Применить выбранные", command=_apply).pack(side=tk.RIGHT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=dlg.destroy).pack(side=tk.RIGHT, padx=2)
dlg.wait_window()
def _check_threats_ai_click(self):
"""🤖 Проверить текущий список угроз через ИИ (не подбирать заново).
Программа/оператор уже подобрали угрозы ИИ проверяет, что лишнее,
чего не хватает, и даёт комментарий.
"""
if self._current_is_idx is None:
messagebox.showinfo("Угрозы", "Сначала выберите объект КИИ в списке слева")
return
settings = load_api_settings()
if not settings.get('ai_api_key'):
messagebox.showwarning("Настройки", "Не задан API-ключ ИИ.\nМеню «Файл → Настройки»")
return
current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())]
if not current:
messagebox.showinfo("Угрозы", "Сначала подберите угрозы (программой или ИИ).")
return
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
from .threat_rules import _has_internet
has_internet = _has_internet(isys)
data = {
'name': isys.name or '',
'description': isys.description or '',
'object_type': isys.object_type or '',
'sphere': isys.sphere or '',
'architecture': isys.architecture or '',
'net_category': isys.net_category or '',
'attacker_category': isys.attacker_category or '',
'has_internet': has_internet,
}
self.log(f"🤖 Запрос к ИИ: проверка угроз для «{isys.name}» (в списке: {len(current)})")
self._verify_log("Проверка угроз через ИИ…")
def _run():
try:
from .verify import check_threats_ai
res, err = check_threats_ai(
data, current, settings['ai_api_key'], settings.get('ai_model'),
settings.get('ai_base_url'))
self.root.after(0, lambda: self._check_threats_ai_done(res, err, isys.name))
except Exception as e:
self.root.after(0, lambda: self._check_threats_ai_done(None, str(e), isys.name))
threading.Thread(target=_run, daemon=True).start()
def _check_threats_ai_done(self, res, err, obj_name):
"""Показ результата проверки угроз ИИ."""
if err:
self.log(f"❌ Проверка угроз: {err}")
self._verify_log(f"{err}")
return
if not isinstance(res, dict):
self.log("❌ ИИ не вернул результат проверки")
return
extra = res.get('лишние') or []
missing = res.get('недостающие') or []
comment = res.get('комментарий') or ''
if isinstance(extra, str):
extra = [extra]
if isinstance(missing, str):
missing = [missing]
lines = [f"🤖 Проверка угроз для «{obj_name}»:"]
lines.append(f"Всего в списке: {self.is_threats_listbox.size()}")
if extra:
lines.append(f"\n❌ Возможно ЛИШНИЕ ({len(extra)}):")
for t in extra:
lines.append(f"{t}")
else:
lines.append("\n❌ Лишних не найдено")
if missing:
lines.append(f"\n➕ Возможно НЕ ХВАТАЕТ ({len(missing)}):")
for t in missing:
lines.append(f"{t}")
else:
lines.append("\n➕ Недостающих не найдено")
if comment:
lines.append(f"\n💬 Комментарий: {comment}")
lines.append("\nРекомендации ИИ — оператор принимает решение.")
dlg = tk.Toplevel(self.root)
dlg.title(f"Проверка угроз ИИ — {obj_name}")
dlg.geometry("860x600")
dlg.transient(self.root)
txt = tk.Text(dlg, wrap=tk.WORD, font=('Arial', 10))
txt.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)
txt.insert('1.0', '\n'.join(lines))
txt.config(state=tk.DISABLED)
def _ubi_code(s):
m = re.search(r'уби\.?\s*(\d+)', str(s).lower())
return m.group(1) if m else None
def _add_missing():
"""Добавить недостающие (только из словаря УБИ, полные названия)."""
lb = self.is_threats_listbox
existing = {(_ubi_code(lb.get(i)) or lb.get(i).strip().lower())
for i in range(lb.size())}
added = 0
for t in missing:
code = _ubi_code(t)
key = code or str(t).strip().lower()
if key in existing:
continue
lb.insert(tk.END, t)
existing.add(key)
added += 1
if added:
self._collect_is()
self.log(f"➕ Добавлено недостающих угроз (по словарю): {added}")
else:
self.log("➕ Недостающие уже есть в списке — ничего не добавлено")
dlg.destroy()
def _remove_extra():
"""Убрать лишние (только те, что реально есть в списке, по коду УБИ)."""
lb = self.is_threats_listbox
extra_codes = {c for c in (_ubi_code(t) for t in extra) if c}
removed = 0
for i in range(lb.size() - 1, -1, -1):
code = _ubi_code(lb.get(i))
if code and code in extra_codes:
lb.delete(i)
removed += 1
if removed:
self._collect_is()
self.log(f"➖ Убрано лишних угроз: {removed}")
else:
self.log("➖ Лишние не найдены в списке — ничего не убрано")
dlg.destroy()
btns = ttk.Frame(dlg)
btns.pack(pady=(0, 8))
if missing:
ttk.Button(btns, text="➕ Добавить недостающие (по словарю)",
command=_add_missing).pack(side=tk.LEFT, padx=4)
if extra:
ttk.Button(btns, text=" Убрать лишние",
command=_remove_extra).pack(side=tk.LEFT, padx=4)
ttk.Button(btns, text="Закрыть",
command=dlg.destroy).pack(side=tk.LEFT, padx=4)
self.log(f"🤖 Проверка угроз ИИ: лишних {len(extra)}, недостающих {len(missing)}")
self._verify_log('\n'.join(lines))
def _check_security_ai(self): def _check_security_ai(self):
"""Проверка вкладки «Безопасность» через ИИ (DeepSeek/OpenAI-совместимый API).""" """Проверка вкладки «Безопасность» через ИИ (DeepSeek/OpenAI-совместимый API)."""
@@ -4136,8 +4452,8 @@ class DokoGenApp:
'chief_fio': 'chiefFio', 'chief_fio': 'chiefFio',
'responsible_position': 'responsiblePosition', 'responsible_position': 'responsiblePosition',
'responsible_fio': 'responsibleFio', 'responsible_fio': 'responsibleFio',
'admin_position': ('administratorPosition', 'ispdnPosition'), 'admin_position': 'administratorPosition',
'admin_fio': ('administratorFio', 'ispdnFio'), 'admin_fio': 'administratorFio',
} }
for entry_key, data_keys in officials_map.items(): for entry_key, data_keys in officials_map.items():
entry = self._official_entries.get(entry_key) entry = self._official_entries.get(entry_key)
+195
View File
@@ -373,9 +373,204 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
}) })
loops['employees_access'] = items loops['employees_access'] = items
# --- Шаблон «Сведения о результатах категорирования» (ФСТЭК) ---
# Цикл объектов КИИ — тот же список, что information_systems.
if is_list:
loops['kii_objects'] = loops.get('information_systems', [])
# Цикл показателей критериев значимости (ПП-127): автоматически
# формируем строки таблицы с обоснованием «применим/не применим» по сфере
# и расчётом для показателей 8 и 9 (как в примере заказчика).
loops['categorization_indicators'] = _build_categorization_indicators(company, is_list)
return loops return loops
# ============================================================
# ПОКАЗАТЕЛИ КРИТЕРИЕВ ЗНАЧИМОСТИ (ПП-127)
# ============================================================
def _num(s):
"""«121 118 270 руб», «1 000», «2 212.5» → число (float) или None."""
s = str(s or '').strip()
if not s:
return None
s = s.replace('\u00a0', ' ').replace(' ', '')\
.replace('руб', '').replace('', '').replace('р.', '').replace(',', '.')
try:
return float(s)
except Exception:
return None
def _load_json(name):
import json as _json
import os as _os
p = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), 'dictionaries', name)
try:
with open(p, encoding='utf-8') as f:
return _json.load(f)
except Exception:
return {}
def _calc_criterion_8_9(company, isys, num):
"""Расчёт показателя 8 (ущерб субъекту КИИ) или 9 (ущерб бюджету РФ).
Возвращает (текст_обоснования, категория или None).
Логика как в авторасчёте программы (окно ПП-127), текст по образцу
заполнения заказчика для ОПК.
"""
# ΔДоходов: ущерб из вкладки «Организация» или балансовая стоимость объекта
d = _num(getattr(company, 'income_loss', '') or '')
if d is None:
d = _num(getattr(isys, 'balance_value', '') or '')
# Доходы за 5 лет
incs = [_num(getattr(company, f'income_year_{i}', '') or '') for i in range(1, 6)]
incs = [x for x in incs if x is not None]
r_avg = (sum(incs) / len(incs)) if incs else None
if num == '8':
if d is None or not r_avg:
return ("Показатель применим. Для расчёта заполните данные организации: "
"«Снижение уровня дохода» и доходы за 5 лет, или балансовую стоимость объекта КИИ.", None)
val_pct = d / r_avg * 100
if val_pct > 20:
cat = 'I'
elif val_pct > 10:
cat = 'II'
elif val_pct >= 1:
cat = 'III'
else:
cat = None
text = (f"Реализация компьютерных атак на объект не приведет к снижению доходов субъекта КИИ. "
f"Объем среднегодового дохода субъекта КИИ (с учетом НДС), усредненный за прошедший "
f"5-летний период, составляет {r_avg:,.2f} руб. В случае выхода из строя объекта КИИ "
f"потребуется замена, установка и настройка, стоимость которых составит около {d:,.2f} руб. "
f"Абсолютный ущерб субъекта КИИ: U={d:,.2f} руб. "
f"Показатель ущерба субъекту КИИ: U_8 = U / R_год = {d:,.2f} / {r_avg:,.2f} "
f"= {val_pct:.6f} %.")
if cat:
text += (f" Так как {val_pct:.6f} % более 1% (пороговое значение для 3-й категории "
f"значимости согласно Постановлению № 127), по данному показателю объекту "
f"присваивается {cat}-я категория значимости.")
else:
text += (f" Так как {val_pct:.6f} % менее 1% (пороговое значение для 3-й категории "
f"значимости согласно Постановлению № 127), по данному показателю объекту "
f"категория значимости не присваивается.")
return text, cat
# num == '9'
if d is None:
return ("Показатель применим. Для расчёта заполните данные организации: "
"«Снижение уровня дохода» или балансовую стоимость объекта КИИ.", None)
profit = _num(getattr(company, 'profit', '') or '')
tax = _num(getattr(company, 'profit_tax', '') or '')
if profit and tax is not None and profit > 0:
knp = tax / profit
dnp = d * knp
if d > profit:
dnp = tax
else:
knp = 0.0
dnp = 0.0
DEFAULT_BUDGET_AVG = 21228053357700.0 # Бусредн — прогноз годового дохода федбюджета, усреднённый за 3 года
b = DEFAULT_BUDGET_AVG
val_pct = dnp / b * 100
if val_pct > 0.001:
cat = 'I'
elif val_pct > 0.0006:
cat = 'II'
elif val_pct > 0.0003:
cat = 'III'
else:
cat = None
text = (f"Реализация компьютерных атак на объект не приведет к снижению доходов федерального бюджета. "
f"Объем прогнозируемого годового дохода федерального бюджета Российской Федерации, "
f"усредненный за планируемый трехлетний период, составляет {b:,.2f} руб. "
f"Коэффициент выплат (отчислений) от налога на прибыль субъекта КИИ = {knp:.4f}. "
f"В случае выхода из строя объекта КИИ потребуется замена, установка и настройка, "
f"стоимость которых составит около {d:,.2f} руб. "
f"Снижение выплат (отчислений) от налога на прибыль субъекта КИИ в бюджеты Российской Федерации "
f"Уабс = {d:,.2f} × {knp:.4f} = {dnp:,.2f} руб. (приближённо). "
f"Показатель ущерба бюджетам = {val_pct:.6f} %.")
if cat:
text += (f" Так как {val_pct:.6f} % более 0,0003% (пороговое значение для 3-й категории "
f"значимости согласно Постановлению № 127), по данному показателю объекту "
f"присваивается {cat}-я категория значимости.")
else:
text += (f" Так как {val_pct:.6f} % менее 0,0003% (пороговое значение для 3-й категории "
f"значимости согласно Постановлению № 127), по данному показателю объекту "
f"категория значимости не присваивается.")
return text, cat
def _build_categorization_indicators(company, is_list):
"""Строки таблицы показателей критериев значимости для шаблона
«Сведения о результатах категорирования» (цикл categorization_indicators).
Для каждого показателя ПП-127 (5 групп, показатели 114 с подпунктами):
section, name, number, value, justification, category.
- Если показатель не применим для сферы объекта value = «Показатель не применим.»,
justification текст из словаря обоснований по сфере (или заготовка).
- Если применим (8/9) автоматический расчёт из данных организации.
"""
import re as _re
criteria = _load_json('criteria_127.json')
applicability = _load_json('criteria_applicability.json')
justifications = _load_json('criteria_justifications.json')
if not isinstance(criteria, list):
criteria = []
sphere = getattr(is_list[0], 'sphere', '') if is_list else ''
sphere = (sphere or '').strip()
appl = applicability.get(sphere) if isinstance(applicability, dict) else None
just = (justifications.get(sphere) or {}) if isinstance(justifications, dict) else {}
isys = is_list[0] if is_list else None
rows = []
for grp in criteria:
section = grp.get('criteria', '')
for ind in grp.get('indicators', []):
num = str(ind.get('num', ''))
name = ind.get('name', '')
m = _re.match(r'(\d+)', num)
grp_num = m.group(1) if m else num
applicable = appl is None or grp_num in appl
value = ''
justification = ''
category = ''
if not applicable:
value = 'Показатель не применим.'
justification = (just.get(num) or just.get(grp_num)
or f"Показатель не применим к объекту КИИ организации сферы «{sphere or ''}».")
elif grp_num in ('8', '9'):
text, cat = _calc_criterion_8_9(company, isys, grp_num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
else:
# Применимый показатель без авторасчёта — оператор заполняет значение
value = ''
justification = (just.get(num) or just.get(grp_num)
or 'Показатель применим к объекту КИИ; значение определяется по данным объекта.')
rows.append({
'section': section,
'name': name,
'number': num,
'value': value,
'justification': justification,
'category': category,
})
return rows
# ============================================================ # ============================================================
# РЕЗОЛВЕР ЗНАЧЕНИЙ # РЕЗОЛВЕР ЗНАЧЕНИЙ
# ============================================================ # ============================================================
+114 -3
View File
@@ -340,8 +340,15 @@ def check_company_dadata(inn, token):
# ОКВЭД: основной (okved) и дополнительные (okveds — список словарей с 'code') # ОКВЭД: основной (okved) и дополнительные (okveds — список словарей с 'code')
okved = d.get('okved', '') or '' okved = d.get('okved', '') or ''
okved_extra = [] okved_extra = []
for ok in (d.get('okveds') or []): raw_okveds = d.get('okveds') or []
code = (ok or {}).get('code') or '' if isinstance(raw_okveds, str):
# DaData иногда отдаёт доп. ОКВЭД одной строкой через запятую
raw_okveds = [x.strip() for x in raw_okveds.split(',') if x.strip()]
for ok in raw_okveds:
if isinstance(ok, dict):
code = (ok.get('code') or '').strip()
else:
code = str(ok or '').strip()
if code: if code:
okved_extra.append(code) okved_extra.append(code)
@@ -518,6 +525,66 @@ def compare_company_values(old_data, new_data):
return diffs return diffs
def _ubi_code(text):
"""Извлекает код УБИ из строки: «УБИ.069 Название» → '069'."""
m = re.search(r'уби\.?\s*(\d+)', str(text), re.IGNORECASE)
return m.group(1) if m else None
def _norm_threat_text(text):
"""Нормализация названия угрозы для сравнения: без ведущего номера, лишних пробелов."""
s = re.sub(r'^\d+[.)]?\s*', '', str(text or '').strip())
s = re.sub(r'\s+', ' ', s).strip()
return s.lower()
def filter_threats_by_dict(lines, threats_full=None):
"""Приводит строки угроз (от ИИ или оператора) к каноническому виду по словарю УБИ.
Для каждой строки:
- есть код УБИ берём полную запись «УБИ.xxx Название» из словаря;
- кода нет ищем по названию (точное совпадение или вхождение);
- не нашлось в словаре отбрасываем (мусор от ИИ).
Возвращает уникальные канонические строки, отсортированные по коду УБИ.
"""
if threats_full is None:
from .threat_rules import load_threats
threats_full = load_threats()
by_code = {}
by_norm = {}
for t in threats_full:
t = str(t).strip()
code = _ubi_code(t)
if code:
by_code.setdefault(code, t)
by_norm.setdefault(_norm_threat_text(t), t)
out = []
seen = set()
for line in lines or []:
line = str(line or '').strip()
if not line:
continue
code = _ubi_code(line)
canon = None
if code and code in by_code:
canon = by_code[code]
else:
n = _norm_threat_text(line)
if n in by_norm:
canon = by_norm[n]
else:
for k, v in by_norm.items():
if n and (n in k or k in n):
canon = v
break
if canon and canon not in seen:
seen.add(canon)
out.append(canon)
out.sort(key=lambda t: (int(_ubi_code(t)) if _ubi_code(t) else 10 ** 9, t))
return out
def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=None, def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=None,
threats=None): threats=None):
"""ИИ предлагает актуальные угрозы БИ для объекта КИИ по его характеристикам. """ИИ предлагает актуальные угрозы БИ для объекта КИИ по его характеристикам.
@@ -564,9 +631,52 @@ def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=N
if isinstance(threats_out, str): if isinstance(threats_out, str):
threats_out = [threats_out] threats_out = [threats_out]
threats_out = [str(t).strip() for t in threats_out if str(t).strip()] threats_out = [str(t).strip() for t in threats_out if str(t).strip()]
# Жёсткий фильтр по словарю: мусор отбрасываем, «УБИ.069» дополняем
# полным названием из актуального перечня (если он передан).
threats_out = filter_threats_by_dict(threats_out, threats)
return threats_out, None return threats_out, None
def check_threats_ai(security_data, current_threats, api_key, model='deepseek-chat', base_url=None):
"""ИИ проверяет уже подобранный список угроз БИ для объекта КИИ.
В отличие от suggest_threats_ai (подбор заново), эта функция проверяет
текущий список: какие угрозы лишние, каких не хватает, и даёт комментарий.
Возвращает (dict, ошибка):
{"лишние": [...], "недостающие": [...], "комментарий": "..."}
"""
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
if not current_threats:
return None, "Список угроз пуст — сначала подберите угрозы (программой или ИИ)."
system = (
"Ты — эксперт ФСТЭК по безопасности критической информационной инфраструктуры "
"(187-ФЗ, приказ ФСТЭК № 236). Проверь, корректно ли подобран перечень угроз "
"безопасности информации для объекта КИИ."
)
user = (
"Характеристики объекта КИИ:\n"
f"{json.dumps(security_data, ensure_ascii=False)}\n\n"
"Текущий перечень угроз (подобран программой или оператором):\n"
+ "\n".join(f"{i+1}. {t}" for i, t in enumerate(current_threats))
+ "\n\nВерни ТОЛЬКО JSON: "
'{"лишние": ["угрозы, которые НЕ актуальны для этого объекта"], '
'"недостающие": ["актуальные угрозы, которых в списке не хватает"], '
'"комментарий": "краткий вывод по перечню"}'
)
js, err = _ai_json(api_key, model, base_url, system, user)
if err:
return None, err
# «Недостающие»/«лишние» — тоже через словарь: мусор отбрасываем,
# неполные строки («УБИ.069») дополняем полными названиями.
missing = filter_threats_by_dict(js.get('недостающие') or [])
extra = filter_threats_by_dict(js.get('лишние') or [])
comment = js.get('комментарий') or ''
return {'лишние': extra, 'недостающие': missing, 'комментарий': comment}, None
def suggest_object_params_ai(object_data, api_key, model='deepseek-chat', base_url=None, def suggest_object_params_ai(object_data, api_key, model='deepseek-chat', base_url=None,
typical_objects=None, spheres=None, object_types=None, typical_objects=None, spheres=None, object_types=None,
architectures=None): architectures=None):
@@ -694,7 +804,8 @@ def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None
if not api_key: if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)" return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
system = ( system = (
"Ты — юрист по защите персональных данных (187-ФЗ). Проверь реквизиты организации. " "Ты — специалист по проверке реквизитов организации (ИНН/КПП/ОГРН). "
"Проверь реквизиты организации. "
"Верни ТОЛЬКО JSON: " "Верни ТОЛЬКО JSON: "
'{"оценка": "ok|warning|error", ' '{"оценка": "ok|warning|error", '
'"замечания": ["..."], ' '"замечания": ["..."], '
+8 -8
View File
@@ -30,8 +30,8 @@ def create_test_template():
doc.add_paragraph('<!-- loop:information_systems -->') doc.add_paragraph('<!-- loop:information_systems -->')
doc.add_paragraph('=== IS: {{item.name}} ===') doc.add_paragraph('=== IS: {{item.name}} ===')
doc.add_paragraph('Category: {{item.category}}') doc.add_paragraph('Category: {{item.category}}')
doc.add_paragraph('Threat type: {{item.threat_type}}') doc.add_paragraph('Sphere: {{item.sphere}}')
doc.add_paragraph('Defence level: {{item.defence_level}}') doc.add_paragraph('Object type: {{item.object_type}}')
doc.add_paragraph('') doc.add_paragraph('')
# Inner loop: commission # Inner loop: commission
@@ -72,12 +72,12 @@ def test_zip_loop_expansion():
CommissionMember(role='Член комиссии', position='Бухгалтер', fio='Сидорова А.А.'), CommissionMember(role='Член комиссии', position='Бухгалтер', fio='Сидорова А.А.'),
], ],
information_systems=[ information_systems=[
InformationSystem(name='ИС-1 "Бухгалтерия"', category='специальные', InformationSystem(name='ОКИИ-1 "Платёжный шлюз"', category='1',
pd_count='менее 100 000', threat_type='1', defence_level='2'), sphere='Банковская сфера и финансовый рынок', object_type='АСУ'),
InformationSystem(name='ИС-2 "Кадры"', category='иные', InformationSystem(name='ОКИИ-2 "Кадры"', category='2',
pd_count='более 100 000', threat_type='3', defence_level='4'), sphere='Наука', object_type='ИС'),
InformationSystem(name='ИС-3 "Склад"', category='общедоступные', InformationSystem(name='ОКИИ-3 "Склад"', category='3',
pd_count='менее 100 000', threat_type='2', defence_level='3'), sphere='Здравоохранение', object_type='ИС'),
] ]
) )