feat: подбор угроз — инвертированная логика (все угрозы минус неактуальные)

- threat_rules: по умолчанию актуальны ВСЕ угрозы БДУ ФСТЭК, исключаются
  заведомо неактуальные: нет интернета → убраны удалённый доступ/веб/почта/
  беспроводные/DDoS/облачные/грид; АСУ → убраны BIOS/грид/виртуализация/браузер
  (теперь 170-200 угроз на объект вместо 15 «самых похожих»)
- _has_internet уточнён: «Отсутствует» у оператора / «изолирован» / «не требуется»
  → внешней сети нет; сеть общего пользования или реальный оператор → есть
- verify.suggest_threats_ai: свежий перечень ФСТЭК (227 угроз) передаётся в промт,
  ИИ выбирает из него, а не из устаревших знаний; + человекочитаемая сводка объекта
- ui: кнопки программа/ИИ без лимита 20; после подбора угрозы сохраняются в модель
  (_collect_is) — при открытии словаря галочки выбранных угроз стоят
- models.from_dict: фильтрация неизвестных полей вложенных объектов — старые файлы
  с удалёнными полями (attacker_characteristics) больше не падают
This commit is contained in:
2026-08-04 17:59:44 +04:00
parent 8569b478d4
commit 6c2e4a0c38
4 changed files with 108 additions and 58 deletions
+6 -2
View File
@@ -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):
+53 -36
View File
@@ -95,17 +95,31 @@ 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
# По умолчанию считаем, что сеть есть (осторожная оценка)
# Указан реальный оператор/провайдер — внешняя связь есть
if operator and operator not in ('нет', 'отсутствует', 'не требуется', '-'):
return True
# Консервативно: без явных признаков внешней сети считаем, что её нет
return False
def _is_insider(isys) -> bool:
a = (getattr(isys, 'attacker_category', '') or '').lower()
@@ -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
+19 -6
View File
@@ -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("Сравнение", "Сначала подберите угрозы (программой или ИИ).")
+29 -13
View File
@@ -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):