From 6c2e4a0c386b8f9296270f9c72ad13f37c954bf2 Mon Sep 17 00:00:00 2001 From: prog1764 Date: Tue, 4 Aug 2026 17:59:44 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D1=83=D0=B3=D1=80=D0=BE=D0=B7=20=E2=80=94=20=D0=B8=D0=BD=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20(=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=20=D1=83=D0=B3=D1=80=D0=BE=D0=B7=D1=8B=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=BD=D1=83=D1=81=20=D0=BD=D0=B5=D0=B0=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - threat_rules: по умолчанию актуальны ВСЕ угрозы БДУ ФСТЭК, исключаются заведомо неактуальные: нет интернета → убраны удалённый доступ/веб/почта/ беспроводные/DDoS/облачные/грид; АСУ → убраны BIOS/грид/виртуализация/браузер (теперь 170-200 угроз на объект вместо 15 «самых похожих») - _has_internet уточнён: «Отсутствует» у оператора / «изолирован» / «не требуется» → внешней сети нет; сеть общего пользования или реальный оператор → есть - verify.suggest_threats_ai: свежий перечень ФСТЭК (227 угроз) передаётся в промт, ИИ выбирает из него, а не из устаревших знаний; + человекочитаемая сводка объекта - ui: кнопки программа/ИИ без лимита 20; после подбора угрозы сохраняются в модель (_collect_is) — при открытии словаря галочки выбранных угроз стоят - models.from_dict: фильтрация неизвестных полей вложенных объектов — старые файлы с удалёнными полями (attacker_characteristics) больше не падают --- dokogen/models.py | 8 +++- dokogen/threat_rules.py | 91 ++++++++++++++++++++++++----------------- dokogen/ui.py | 25 ++++++++--- dokogen/verify.py | 42 +++++++++++++------ 4 files changed, 108 insertions(+), 58 deletions(-) diff --git a/dokogen/models.py b/dokogen/models.py index 792013f..1dde8a8 100644 --- a/dokogen/models.py +++ b/dokogen/models.py @@ -157,13 +157,17 @@ class Company: def from_dict(cls, data: Dict[str, Any]) -> "Company": d = dict(data) if "commission" in d and isinstance(d["commission"], list): + _cvalid = {f.name for f in CommissionMember.__dataclass_fields__.values()} d["commission"] = [ - CommissionMember(**c) if isinstance(c, dict) else c + CommissionMember(**{k: v for k, v in c.items() if k in _cvalid}) + if isinstance(c, dict) else c for c in d["commission"] ] if "information_systems" in d and isinstance(d["information_systems"], list): + _ivalid = {f.name for f in InformationSystem.__dataclass_fields__.values()} d["information_systems"] = [ - InformationSystem(**isys) if isinstance(isys, dict) else isys + InformationSystem(**{k: v for k, v in isys.items() if k in _ivalid}) + if isinstance(isys, dict) else isys for isys in d["information_systems"] ] if "employees_access" in d and not isinstance(d["employees_access"], list): diff --git a/dokogen/threat_rules.py b/dokogen/threat_rules.py index 392c106..bd3c8c3 100644 --- a/dokogen/threat_rules.py +++ b/dokogen/threat_rules.py @@ -95,16 +95,30 @@ def _has_internet(isys) -> bool: """Есть ли подключение к сети Интернет / внешним сетям.""" if getattr(isys, 'is_internet', False): return True - # По описанию / сетям: «отсутствует подключение к сети Интернет» → нет - desc = ((getattr(isys, 'description', '') or '') + ' ' + - (getattr(isys, 'net_operator', '') or '')).lower() + desc = ((getattr(isys, 'description', '') or '') + ' ').lower() + operator = (getattr(isys, 'net_operator', '') or '').lower() + net_category = (getattr(isys, 'net_category', '') or '').lower() + + # Явные признаки ОТСУТСТВИЯ внешней сети + if 'отсутств' in operator: # оператор «Отсутствует» + return False if 'отсутств' in desc and ('интернет' in desc or 'подключ' in desc): return False - net_category = (getattr(isys, 'net_category', '') or '').lower() - if net_category: + if 'не требуется' in desc and ('доменн' in desc or 'интернет' in desc): + return False + if 'изолирован' in desc: + return False + + # Сеть общего пользования — это и есть внешняя сеть (Интернет) + if 'общего пользован' in net_category: return True - # По умолчанию считаем, что сеть есть (осторожная оценка) - return True + + # Указан реальный оператор/провайдер — внешняя связь есть + if operator and operator not in ('нет', 'отсутствует', 'не требуется', '-'): + return True + + # Консервативно: без явных признаков внешней сети считаем, что её нет + return False def _is_insider(isys) -> bool: @@ -117,8 +131,16 @@ def _score(threat_lower, keywords): return sum(1 for kw in keywords if kw in threat_lower) -def suggest_threats_program(isys, threats=None, limit=15): - """Подбор угроз по характеристикам объекта КИИ. +def suggest_threats_program(isys, threats=None, limit=None): + """Подбор угроз по характеристикам объекта КИИ (инвертированная логика). + + По умолчанию актуальны ВСЕ угрозы из перечня (БДУ ФСТЭК). + Исключаются только заведомо неактуальные для данного объекта: + - нет сети Интернет → убираем угрозы удалённого доступа, веб, почты, + беспроводных сетей, DDoS, облачных и т.п.; + - объект типа АСУ → убираем угрозы BIOS, грид-систем, виртуализации + (у технологического оборудования их, как правило, нет). + Такой подход даёт полный список, а не «15 самых похожих». Возвращает список строк (с кодами УБИ, если они есть в перечне). """ @@ -130,39 +152,34 @@ def suggest_threats_program(isys, threats=None, limit=15): has_internet = _has_internet(isys) is_asu = _is_asu(isys) is_network = _is_network(isys) - insider = _is_insider(isys) - # Собираем активные наборы ключевых слов - active = list(BASE_KEYWORDS) - if has_internet: - active += INTERNET_KEYWORDS - else: - active += ISOLATED_KEYWORDS + # --- Слова-исключения (если встретились в названии угрозы — она неактуальна) --- + exclude = [] + + if not has_internet: + # Объект изолирован: угрозы, реализуемые только через внешние сети + exclude += [ + 'удалённ', 'удаленн', 'дистанционн', 'веб', 'электронн', 'почт', + 'фишинг', 'беспроводн', 'wi-fi', 'wifi', 'интернет', 'ddos', 'vpn', + 'облачн', 'грид', 'внешн', + ] + if is_asu: - active += ASU_KEYWORDS - if is_network: - active += NETWORK_KEYWORDS - if insider: - active += INSIDER_KEYWORDS - else: - active += EXTERNAL_KEYWORDS + # АСУ: нет BIOS/виртуализации/браузера в привычном понимании + exclude += ['биос', 'грид', 'гипервизор', 'виртуальн', 'браузер'] - scored = [] + if not is_network and not is_asu: + # Обычная ИС: неактуальны чисто сетевые/телеком-угрозы без указания + # — оставляем всё, кроме специфики каналов связи, если их нет + pass + + result = [] for t in threats: tl = str(t).lower() - s = _score(tl, active) - if s > 0: - scored.append((s, t)) - - # Сортировка: больше совпадений — выше; убираем дубликаты, сохраняя порядок - scored.sort(key=lambda x: -x[0]) - result = [] - seen = set() - for s, t in scored: - if t in seen: + if any(kw in tl for kw in exclude): continue - seen.add(t) result.append(t) - if len(result) >= limit: - break + + if limit and len(result) > limit: + result = result[:limit] return result diff --git a/dokogen/ui.py b/dokogen/ui.py index 66291a3..957f9ee 100644 --- a/dokogen/ui.py +++ b/dokogen/ui.py @@ -1900,7 +1900,7 @@ class DokoGenApp: isys = self.model.information_systems[self._current_is_idx] try: from .threat_rules import suggest_threats_program - threats = suggest_threats_program(isys, limit=20) + threats = suggest_threats_program(isys) except Exception as e: messagebox.showerror("Ошибка", str(e)) return @@ -1912,6 +1912,7 @@ class DokoGenApp: lb.delete(0, tk.END) for t in threats: lb.insert(tk.END, t) + self._collect_is() # сохранить в модель — галочки в словаре будут стоять self.log(f"⚙️ Угрозы подобраны программой: {len(threats)} шт.") def _suggest_threats_ai_click(self): @@ -1925,7 +1926,8 @@ class DokoGenApp: return self._collect_is() isys = self.model.information_systems[self._current_is_idx] - from .threat_rules import _has_internet + from .threat_rules import _has_internet, load_threats + has_internet = _has_internet(isys) data = { 'name': isys.name or '', 'description': isys.description or '', @@ -1934,16 +1936,26 @@ class DokoGenApp: 'architecture': isys.architecture or '', 'net_category': isys.net_category or '', 'attacker_category': isys.attacker_category or '', - 'has_internet': _has_internet(isys), + 'has_internet': has_internet, + 'summary': ( + f"Объект КИИ: {isys.name or '—'}. " + f"Тип: {isys.object_type or '—'}. " + f"Сфера: {isys.sphere or '—'}. " + f"Выход в интернет: {'есть' if has_internet else 'нет'}. " + f"Категория нарушителя: {isys.attacker_category or '—'}. " + f"Сети: {isys.net_category or '—'}" + ), } - self.log(f"🤖 Запрос к ИИ: подбор угроз для «{isys.name}»") + threats_full = load_threats() # свежий перечень с сайта ФСТЭК + self.log(f"🤖 Запрос к ИИ: подбор угроз для «{isys.name}» (перечень: {len(threats_full)} угроз)") self._verify_log("Подбор угроз через ИИ…") def _run(): try: from .verify import suggest_threats_ai threats, err = suggest_threats_ai( - data, settings['ai_api_key'], settings.get('ai_model'), settings.get('ai_base_url')) + data, settings['ai_api_key'], settings.get('ai_model'), + settings.get('ai_base_url'), threats=threats_full) self.root.after(0, lambda: self._suggest_threats_ai_done(threats, err)) except Exception as e: self.root.after(0, lambda: self._suggest_threats_ai_done(None, str(e))) @@ -1962,6 +1974,7 @@ class DokoGenApp: lb.delete(0, tk.END) for t in threats: lb.insert(tk.END, t) + self._collect_is() # сохранить в модель — галочки в словаре будут стоять self.log(f"🤖 ИИ подобрал угроз: {len(threats)}") self._verify_log(f"🤖 ИИ: {len(threats)} угроз") @@ -1973,7 +1986,7 @@ class DokoGenApp: self._collect_is() isys = self.model.information_systems[self._current_is_idx] from .threat_rules import suggest_threats_program - program = suggest_threats_program(isys, limit=20) + program = suggest_threats_program(isys) current = [str(self.is_threats_listbox.get(i)) for i in range(self.is_threats_listbox.size())] if not current: messagebox.showinfo("Сравнение", "Сначала подберите угрозы (программой или ИИ).") diff --git a/dokogen/verify.py b/dokogen/verify.py index dec8cef..caed246 100644 --- a/dokogen/verify.py +++ b/dokogen/verify.py @@ -474,33 +474,49 @@ def compare_company_values(old_data, new_data): return diffs -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): """ИИ предлагает актуальные угрозы БИ для объекта КИИ по его характеристикам. security_data — dict: name, description, object_type, sphere, architecture, attacker_category, has_internet (True/False), etc. + threats — актуальный перечень угроз БДУ ФСТЭК (список строк «УБИ.xxx …»). + Передаётся в промт целиком, чтобы ИИ выбирал из свежего списка, + а не из своего устаревшего. + Возвращает (список_угроз, ошибка). """ if not api_key: return None, "Не задан API-ключ ИИ (Файл → Настройки → API)" - internet = 'да' if security_data.get('has_internet', True) else 'нет' + + if threats: + threat_list_text = "\n".join(f"{i+1}. {t}" for i, t in enumerate(threats)) + else: + threat_list_text = "(перечень не предоставлен — используй знания об актуальном перечне БДУ ФСТЭК)" + system = ( "Ты — эксперт ФСТЭК по безопасности критической информационной инфраструктуры " - "(187-ФЗ, приказ ФСТЭК № 236). На основе характеристик объекта КИИ составь " - "перечень АКТУАЛЬНЫХ угроз безопасности информации из БДУ ФСТЭК " - "(перечень угроз УБИ.xxx). Верни ТОЛЬКО JSON: " - '{"threats": ["УБИ.xxx Название угрозы", ...]} — от 8 до 15 угроз, ' - "без пояснений и комментариев." + "(187-ФЗ, приказ ФСТЭК № 236). На основе характеристик объекта КИИ выбери из " + "предоставленного перечня АКТУАЛЬНЫЕ угрозы безопасности информации и верни " + "ТОЛЬКО JSON: " + '{"threats": ["УБИ.xxx Название угрозы", ...]} — от 10 до 40 угроз, ' + "без пояснений и комментариев. Не выдумывай угрозы, которых нет в перечне; " + "используй полные строки из перечня (код + название) как они есть." + ) + user = ( + "Характеристики объекта КИИ:\n" + f"{json.dumps(security_data, ensure_ascii=False)}\n\n" + "Актуальный перечень угроз БДУ ФСТЭК:\n" + f"{threat_list_text}" ) - user = json.dumps(security_data, ensure_ascii=False) js, err = _ai_json(api_key, model, base_url, system, user) if err: return None, err - threats = js.get('threats') or [] - if isinstance(threats, str): - threats = [threats] - threats = [str(t).strip() for t in threats if str(t).strip()] - return threats, None + threats_out = js.get('threats') or [] + if isinstance(threats_out, str): + threats_out = [threats_out] + threats_out = [str(t).strip() for t in threats_out if str(t).strip()] + return threats_out, None def check_security_ai(security_data, api_key, model='deepseek-chat', base_url=None):