feat: перенос вкладки «Безопасность» из DokoGen 187-ФЗ

- security_tab_187_reference.py — код вкладки (секция построения +
  методы-обработчики + ИИ-проверка) для переноса интерфейса
- словари: attacker_categories, incidents, criteria_indicators,
  org_measures, tech_measures, threats, threats_fstec (+дата актуализации)
- fstec_threats.py — скачивание перечня угроз с bdu.fstec.ru
- verify.py: check_security_ai — ИИ-проверка безопасности по 236 приказу
This commit is contained in:
LocoAgent
2026-08-04 17:36:02 +04:00
parent de522360b8
commit baa8f095d0
11 changed files with 953 additions and 0 deletions
+480
View File
@@ -0,0 +1,480 @@
# -*- coding: utf-8 -*-
"""
ВКЛАДКА «БЕЗОПАСНОСТЬ» из DokoGen 187-ФЗ (dokogen187, ветка source)
=====================================================================
Скопировано для переноса в DokoGen MU, чтобы интерфейс был одинаковым.
ЧТО ВХОДИТ:
1. Секция построения вкладки (вставить в метод _build_is_tab перед вкладкой «Средства защиты»)
2. Методы-обработчики (вставить в класс приложения)
3. Словари — скопированы в dokogen/dictionaries/:
attacker_categories.json, incidents.json, criteria_indicators.json,
org_measures.json, tech_measures.json, threats.json,
threats_fstec.json, threats_fstec_date.txt
4. Модуль fstec_threats.py (скачивание перечня угроз с bdu.fstec.ru)
5. check_security_ai() — ИИ-проверка (см. конец файла, добавить в verify.py)
ЗАВИСИМОСТИ:
- tkinter/ttk (стандарт)
- dokogen/fstec_threats.py (загрузка угроз ФСТЭК)
- dokogen/verify.py: check_security_ai (ИИ-проверка)
- dokogen/api_settings.py: load_api_settings
- Поля модели InformationSystem (добавить в models.py, если нет):
attacker_category, attacker_characteristics, threats, incidents,
criteria_values, criteria_justification, org_measures, tech_measures
- Маппинги _collect_is/_on_is_select (см. комментарии в конце)
ПРИМЕЧАНИЕ: в DokoGen MU (152-ФЗ) модель InformationSystem другая —
при переносе адаптировать имена полей или добавить новые.
"""
# ============================================================
# 1. СЕКЦИЯ ПОСТРОЕНИЯ ВКЛАДКИ (вставить в _build_is_tab)
# ============================================================
# ===== Вкладка «Безопасность» =====
sec_tab = ttk.Frame(nb2)
nb2.add(sec_tab, text="Безопасность")
sec_frame = ttk.Frame(sec_tab, padding=8)
sec_frame.pack(fill=tk.BOTH, expand=True)
# --- Категория нарушителя (словарик) ---
ttk.Label(sec_frame, text="Категория нарушителя:").grid(row=0, column=0, sticky=tk.W, pady=2)
self.is_attacker_category_combo = ttk.Combobox(sec_frame, width=68, state='readonly')
self.is_attacker_category_combo.grid(row=0, column=1, padx=8, sticky=tk.EW, pady=1)
try:
import json as _json
_ac = os.path.join(os.path.dirname(__file__), 'dictionaries', 'attacker_categories.json')
with open(_ac, encoding='utf-8') as _f:
self.is_attacker_category_combo['values'] = [''] + _json.load(_f)
except Exception:
pass
# --- Характеристика нарушителя ---
ttk.Label(sec_frame, text="Характеристика нарушителя:").grid(row=1, column=0, sticky=tk.NW, pady=2)
self.is_attacker_characteristics_listbox = tk.Listbox(sec_frame, height=3, exportselection=False)
self.is_attacker_characteristics_listbox.grid(row=1, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_char_btn = ttk.Frame(sec_frame)
sec_char_btn.grid(row=1, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_char_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_attacker_characteristics_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_char_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_attacker_characteristics_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_char_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_attacker_characteristics_listbox)).pack(fill=tk.X, pady=1)
# --- Основные угрозы БИ (словарик + загрузка с ФСТЭК) ---
ttk.Label(sec_frame, text="Основные угрозы БИ:").grid(row=2, column=0, sticky=tk.W, pady=2)
self.is_threats_listbox = tk.Listbox(sec_frame, height=5, exportselection=False)
self.is_threats_listbox.grid(row=2, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_thr_btn = ttk.Frame(sec_frame)
sec_thr_btn.grid(row=2, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_thr_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_threats_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_thr_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_threats_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_thr_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_threats_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_thr_btn, text="📚 Из словаря",
command=lambda: self._select_sw_from_dict(self.is_threats_listbox, 'threats_fstec.json')).pack(fill=tk.X, pady=1)
ttk.Button(sec_thr_btn, text="⬇️ Загрузить угрозы ФСТЭК",
command=self._download_fstec_threats).pack(fill=tk.X, pady=1)
self._threats_date_label = ttk.Label(sec_frame, text="", foreground='gray')
self._threats_date_label.grid(row=3, column=1, sticky=tk.W, padx=8, pady=(0, 2))
# --- Типы компьютерных инцидентов (словарик) ---
ttk.Label(sec_frame, text="Типы компьютерных инцидентов:").grid(row=4, column=0, sticky=tk.W, pady=2)
self.is_incidents_listbox = tk.Listbox(sec_frame, height=4, exportselection=False)
self.is_incidents_listbox.grid(row=4, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_inc_btn = ttk.Frame(sec_frame)
sec_inc_btn.grid(row=4, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_inc_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_incidents_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_inc_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_incidents_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_inc_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_incidents_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_inc_btn, text="📚 Из словаря",
command=lambda: self._select_sw_from_dict(self.is_incidents_listbox, 'incidents.json')).pack(fill=tk.X, pady=1)
# --- Показатели критериев значимости (словарик) ---
ttk.Label(sec_frame, text="Показатели критериев значимости:").grid(row=5, column=0, sticky=tk.W, pady=2)
self.is_criteria_values_listbox = tk.Listbox(sec_frame, height=4, exportselection=False)
self.is_criteria_values_listbox.grid(row=5, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_crit_btn = ttk.Frame(sec_frame)
sec_crit_btn.grid(row=5, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_crit_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_criteria_values_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_crit_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_criteria_values_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_crit_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_criteria_values_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_crit_btn, text="📚 Из словаря",
command=lambda: self._select_sw_from_dict(self.is_criteria_values_listbox, 'criteria_indicators.json')).pack(fill=tk.X, pady=1)
# --- Обоснование значений показателей ---
ttk.Label(sec_frame, text="Обоснование значений показателей:").grid(row=6, column=0, sticky=tk.NW, pady=2)
self.is_criteria_justification_listbox = tk.Listbox(sec_frame, height=3, exportselection=False)
self.is_criteria_justification_listbox.grid(row=6, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_just_btn = ttk.Frame(sec_frame)
sec_just_btn.grid(row=6, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_just_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_criteria_justification_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_just_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_criteria_justification_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_just_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_criteria_justification_listbox)).pack(fill=tk.X, pady=1)
# --- Организационные меры (словарик) ---
ttk.Label(sec_frame, text="Организационные меры:").grid(row=7, column=0, sticky=tk.W, pady=2)
self.is_org_measures_listbox = tk.Listbox(sec_frame, height=3, exportselection=False)
self.is_org_measures_listbox.grid(row=7, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_org_btn = ttk.Frame(sec_frame)
sec_org_btn.grid(row=7, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_org_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_org_measures_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_org_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_org_measures_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_org_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_org_measures_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_org_btn, text="📚 Из словаря",
command=lambda: self._select_sw_from_dict(self.is_org_measures_listbox, 'org_measures.json')).pack(fill=tk.X, pady=1)
# --- Технические меры (словарик) ---
ttk.Label(sec_frame, text="Технические меры:").grid(row=8, column=0, sticky=tk.W, pady=2)
self.is_tech_measures_listbox = tk.Listbox(sec_frame, height=3, exportselection=False)
self.is_tech_measures_listbox.grid(row=8, column=1, sticky=tk.EW, padx=(8, 4), pady=1)
sec_tech_btn = ttk.Frame(sec_frame)
sec_tech_btn.grid(row=8, column=2, sticky=tk.N, padx=(4, 0))
ttk.Button(sec_tech_btn, text="+ Добавить",
command=lambda: self._add_sw_item(self.is_tech_measures_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_tech_btn, text="- Удалить",
command=lambda: self._del_sw_item(self.is_tech_measures_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_tech_btn, text="✏️ Редактировать",
command=lambda: self._edit_sw_item(self.is_tech_measures_listbox)).pack(fill=tk.X, pady=1)
ttk.Button(sec_tech_btn, text="📚 Из словаря",
command=lambda: self._select_sw_from_dict(self.is_tech_measures_listbox, 'tech_measures.json')).pack(fill=tk.X, pady=1)
# --- Кнопка ИИ-проверки ---
ttk.Button(sec_frame, text="🤖 Проверить безопасность через ИИ",
command=self._check_security_ai).grid(
row=9, column=0, columnspan=3, sticky=tk.W, pady=(10, 0))
sec_frame.columnconfigure(1, weight=1)
# ============================================================
# 2. МЕТОДЫ-ОБРАБОТЧИКИ (вставить в класс приложения)
# ============================================================
def _download_fstec_threats(self):
"""Скачать перечень угроз с сайта ФСТЭК (БДУ) и обновить список."""
try:
from .fstec_threats import download_fstec_threats, get_threats_date
except ImportError as e:
messagebox.showerror("Ошибка", f"Модуль fstec_threats не найден: {e}")
return
self.log("⬇️ Загрузка перечня угроз с bdu.fstec.ru…")
ok, msg, threats = download_fstec_threats()
if not ok:
messagebox.showerror("Загрузка угроз",
f"Не удалось загрузить:\n{msg}\n\n"
"Проверьте доступ к интернету или попробуйте позже.")
self.log(f"❌ Загрузка угроз: {msg}")
return
# Обновляем label даты актуализации
self._update_threats_date_label()
# Если список угроз уже открыт — можно показать диалог выбора
messagebox.showinfo("Загрузка угроз",
f"{msg}\n\nСписок сохранён. Теперь нажмите «📚 Из словаря» "
"в разделе «Основные угрозы БИ», чтобы выбрать нужные угрозы.")
self.log(f"{msg}")
def _update_threats_date_label(self):
"""Обновить подпись даты актуализации списка угроз."""
try:
from .fstec_threats import get_threats_date
d = get_threats_date()
except Exception:
d = None
if hasattr(self, '_threats_date_label'):
if d:
self._threats_date_label.config(
text=f"Актуально на: {d}", foreground='#2e7d32')
else:
self._threats_date_label.config(
text="⚠ Список угроз ещё не скачан — нажмите «⬇️ Загрузить угрозы ФСТЭК»",
foreground='#c62828')
def _check_security_ai(self):
"""Проверка вкладки «Безопасность» через ИИ (DeepSeek/OpenAI-совместимый API)."""
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
# Собираем текущие данные безопасности объекта
self._collect_is()
isys = self.model.information_systems[self._current_is_idx]
security_data = {
'name': isys.name or '',
'description': isys.description or '',
'category': isys.category or '',
'sphere': isys.sphere or '',
'object_type': isys.object_type or '',
'architecture': isys.architecture or '',
'attacker_category': isys.attacker_category or '',
'attacker_characteristics': isys.attacker_characteristics or '',
'threats': isys.threats or '',
'incidents': isys.incidents or '',
'criteria_values': isys.criteria_values or '',
'criteria_justification': isys.criteria_justification or '',
'org_measures': isys.org_measures or '',
'tech_measures': isys.tech_measures or '',
}
self.log(f"🤖 Запрос к ИИ ({settings.get('ai_model') or 'deepseek-chat'}): проверка безопасности")
self._verify_log("Проверка безопасности через ИИ…")
def _run():
try:
from .verify import check_security_ai
result, err = check_security_ai(
security_data,
settings['ai_api_key'],
settings.get('ai_model'),
settings.get('ai_base_url'),
)
self.root.after(0, lambda: self._security_ai_done(result, err))
except Exception as e:
self.root.after(0, lambda: self._security_ai_done(None, str(e)))
threading.Thread(target=_run, daemon=True).start()
def _security_ai_done(self, result, err):
"""Обработка ответа ИИ по безопасности."""
if err:
self._verify_log(f"{err}")
self.log(f"❌ Проверка безопасности: {err}")
return
d = result['data']
lines = [f"Источник: {result['source']}"]
lines.append(f"Оценка: {d.get('rating', 'warning')}")
notes = d.get('notes') or []
if notes:
lines.append("\nЗамечания:")
for n in notes:
lines.append(f"{n}")
props = d.get('proposals') or {}
if any(props.values()):
lines.append("\nПредложения ИИ:")
for key, label in [('attacker_category', 'Нарушитель'),
('threats', 'Угрозы'),
('incidents', 'Инциденты'),
('criteria_values', 'Показатели'),
('criteria_justification', 'Обоснование'),
('org_measures', 'Орг. меры'),
('tech_measures', 'Тех. меры')]:
v = props.get(key)
if v:
lines.append(f" {label}: {v}")
self._verify_log('\n'.join(lines))
self.log(f"🤖 ИИ: оценка {d.get('rating', 'warning')}, замечаний: {len(notes)}")
def _refine_dict_item_dialog(self, base_item):
"""Окно уточнения модели и количества для позиции из словаря.
Возвращает полную строку вида «Windows 10 Pro 5 шт.» или
«Сервер Supermicro SSG-2027R -E1R24N 2 шт.» или None.
"""
import re
# Убираем из базы маркер «(уточнить …)»
base = re.sub(r'\s*\([^)]*уточн[^)]*\)', '', base_item, flags=re.I).strip() or base_item
dialog = tk.Toplevel(self.root)
dialog.title("Уточнить модель и количество")
dialog.geometry("500x180")
dialog.transient(self.root)
dialog.grab_set()
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text=f"Позиция: {base}", wraplength=470,
font=('Arial', 9, 'bold')).pack(anchor=tk.W, pady=(0, 8))
ttk.Label(frame, text="Модель / версия / конфигурация (необязательно):").pack(anchor=tk.W)
model_entry = ttk.Entry(frame, width=60)
model_entry.pack(fill=tk.X, pady=(2, 6))
model_entry.focus_set()
ttk.Label(frame, text="Количество:").pack(anchor=tk.W)
count_entry = ttk.Entry(frame, width=20)
count_entry.pack(anchor=tk.W, pady=(2, 6))
result = {}
def _ok():
model = model_entry.get().strip()
count = count_entry.get().strip()
text = base
if model:
text = f"{text} {model}"
if count:
text = f"{text} {count} шт."
result['val'] = text
dialog.destroy()
def _cancel():
result['val'] = None
dialog.destroy()
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill=tk.X, pady=(6, 0))
ttk.Button(btn_frame, text="✅ ОК", command=_ok).pack(side=tk.RIGHT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=2)
dialog.bind('<Return>', lambda e: _ok())
dialog.bind('<Escape>', lambda e: _cancel())
dialog.wait_window()
return result.get('val')
def _select_sw_from_dict(self, listbox, dict_name):
"""Выбор позиций из словаря (JSON) — можно несколько."""
import json
dict_path = os.path.join(os.path.dirname(__file__), 'dictionaries', dict_name)
try:
with open(dict_path, 'r', encoding='utf-8') as f:
items = json.load(f)
title = "Выбор из словаря"
self._select_from_dialog(title, items, listbox)
except Exception as e:
messagebox.showerror("Ошибка", f"Не удалось загрузить словарь: {e}")
def _sw_dialog(self, title, initial=""):
"""Большое окно для ввода/редактирования позиции ПО."""
dialog = tk.Toplevel(self.root)
dialog.title(title)
dialog.geometry("560x220")
dialog.transient(self.root)
dialog.grab_set()
dialog.resizable(True, True)
frame = ttk.Frame(dialog, padding=10)
frame.pack(fill=tk.BOTH, expand=True)
ttk.Label(frame, text="Текст позиции:").pack(anchor=tk.W)
txt = tk.Text(frame, width=70, height=6, wrap=tk.WORD)
txt.pack(fill=tk.BOTH, expand=True, pady=5)
txt.insert('1.0', initial)
txt.focus_set()
result = {}
def _ok():
result['val'] = txt.get('1.0', 'end-1c').strip()
dialog.destroy()
def _cancel():
result['val'] = None
dialog.destroy()
btn_frame = ttk.Frame(frame)
btn_frame.pack(fill=tk.X)
ttk.Button(btn_frame, text="✅ ОК", command=_ok).pack(side=tk.RIGHT, padx=2)
ttk.Button(btn_frame, text="Отмена", command=_cancel).pack(side=tk.RIGHT, padx=2)
dialog.bind('<Return>', lambda e: _ok())
dialog.bind('<Escape>', lambda e: _cancel())
dialog.wait_window()
return result.get('val')
def _add_sw_item(self, listbox):
"""Добавить строку в список ПО (большое окно)."""
item = self._sw_dialog("Добавить позицию")
if item:
listbox.insert(tk.END, item)
def _del_sw_item(self, listbox):
"""Удалить выбранную строку из списка ПО."""
sel = listbox.curselection()
if sel:
listbox.delete(sel[0])
def _edit_sw_item(self, listbox):
"""Редактировать выбранную строку списка ПО (большое окно)."""
sel = listbox.curselection()
if not sel:
messagebox.showinfo("Редактировать", "Сначала выберите позицию в списке")
return
current = listbox.get(sel[0])
item = self._sw_dialog("Редактировать позицию", initial=current)
if item is not None:
listbox.delete(sel[0])
listbox.insert(sel[0], item)
# ============================================================
# 3. ИИ-ПРОВЕРКА (добавить в verify.py)
# ============================================================
def check_security_ai(security_data, api_key, model='deepseek-chat', base_url=None):
"""ИИ-проверка заполненной вкладки «Безопасность» объекта КИИ (236 приказ).
Возвращает (результат, ошибка): результат — dict с 'оценка', 'замечания', 'предложения'.
"""
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
system = (
"Ты — эксперт по безопасности критической информационной инфраструктуры (187-ФЗ, "
"приказ ФСТЭК России от 25.12.2017 № 239 «Об утверждении Требований по обеспечению "
"безопасности значимых объектов КИИ» и приказ № 236). Проверь заполненные сведения "
"об объекте КИИ и дай рекомендации. Верни ТОЛЬКО JSON: "
'{"оценка": "ok|warning|error", '
'"замечания": ["..."], '
'"предложения": {"attacker_category": "...", "threats": "...", "incidents": "...", '
'"criteria_values": "...", "criteria_justification": "...", '
'"org_measures": "...", "tech_measures": "..."}} '
"В «предложениях» укажи, что добавить/исправить по каждому разделу, если нужно, иначе пустые строки."
)
user = json.dumps(security_data, ensure_ascii=False)
js, err = _ai_json(api_key, model, base_url, system, user)
if err:
return None, err
notes = js.get('замечания') or []
if isinstance(notes, str):
notes = [notes]
proposals = js.get('предложения') or {}
if not isinstance(proposals, dict):
proposals = {}
return {
'source': 'ИИ',
'source_url': 'openai-совместимый API',
'disclaimer': AI_DISCLAIMER,
'kind': 'security',
'data': {
'rating': js.get('оценка', 'warning'),
'notes': notes,
'proposals': proposals,
},
}, None
# ============================================================
# 4. МАППИНГИ ПОЛЕЙ
# ============================================================
# В _collect_is() (список виджетов → модель):
# ('is_attacker_category_combo', 'attacker_category'),
# ('is_attacker_characteristics_listbox', 'attacker_characteristics'),
# ('is_threats_listbox', 'threats'),
# ('is_incidents_listbox', 'incidents'),
# ('is_criteria_values_listbox', 'criteria_values'),
# ('is_criteria_justification_listbox', 'criteria_justification'),
# ('is_org_measures_listbox', 'org_measures'),
# ('is_tech_measures_listbox', 'tech_measures'),
# В _on_is_select() — обратный порядок (combo → .set(), listbox → split('\n'))
# После построения вкладок вызвать: self._update_threats_date_label()