Compare commits

...
26 Commits
Author SHA1 Message Date
LocoAgent 691b167725 Фикс UnboundLocalError в окне авторасчёта ПП-127
Причина: внутри _calc() в ветках стояли импорты
'from .variables import CRITERION_QUANT_THRESHOLDS...' — Python считает
эти имена локальными для всей функции, а условие
'if (num in CRITERION_QUANT_THRESHOLDS ...)' в начале _calc обращалось
к ним до присваивания → UnboundLocalError при любом расчёте.

Исправление: все импорты из .variables вынесены на уровень
_open_calc_dialog (начало функции); внутренние импорты из _calc()
и дубликаты в теле окна убраны.
2026-08-05 17:03:05 +04:00
LocoAgent a7878804e4 Отраслевые ПП: применимость показателей + формулы ОПК
- criteria_applicability.json: Атомная (ПП-4: без 4, 10-10.7, 14; +6),
  Связь (ПП-402: +6, 8, 9), Оборонная (ПП-796: 1-4, 6-13.1, без 5/10/14),
  Транспорт (+8, 9, 13, 14), Наука (1, 7-9, 11, 13)
- variables.py: _calc_criterion_8_opk (U8=(C+A+X)/Rгод*100%, ПП-796 п.18-20),
  _calc_criterion_13_opk (13а: V=(1-(Vmax-Vинц)/VГОЗ)*100%, 13б: T=(1-tнорм/tненорм)*100%)
- ui.py: ОПК-поля в окне авторасчёта (C/A/X, Vmax/Vинц/VГОЗ, tнорм/tненорм),
  сохранение в criterion_input
2026-08-05 16:57:49 +04:00
LocoAgent 8e97e10714 Опросник: добавлен словарь criterion_fields (20 показателей ПП-127), импортёр распознаёт строки «Показатель N …» в листе объекта и складывает в criterion_input; фильтр по сфере (берутся только применимые показатели) 2026-08-05 15:39:06 +04:00
LocoAgent dbf8c613dd Окно авторасчёта сохраняет введённые значения показателей в criterion_input (модель) — автогенерация шаблона использует их 2026-08-05 15:28:35 +04:00
LocoAgent b2b68258fd Авторасчёт всех показателей ПП-127: поле criterion_input в модели (введённые значения из опросника/окна), автогенерация шаблона считает категории по введённым значениям (1–7, 11–14); фикс остановки расчёта (I-я категория вместо startswith('I')) 2026-08-05 15:27:24 +04:00
LocoAgent 1840f1e2d4 Авторасчёт остальных показателей ПП-127 (1–7, 11–14): количественные — ввод значения → категория по порогам (человек, тыс. чел., часы, %), качественные — выбор варианта III/II/I из справочника; кнопка «🧮» для всех показателей 2026-08-05 15:19:11 +04:00
LocoAgent e15610ba29 Удалить случайно загруженный документ (методичка Минздрава по категорированию КИИ сферы здравоохранения) — файл сохранён локально в dokogen/documents 2026-08-05 15:12:21 +04:00
kneon223557 36d2d5a87b Загрузить файлы в «/» 2026-08-05 15:02:44 +04:00
LocoAgent c90660a0f8 ПП-127 п.6: остановка расчёта по остальным показателям при присвоении I категории (в автогенерации шаблона категорирования — «расчёт не проводится») 2026-08-05 15:01:12 +04:00
LocoAgent 5bb6a2f5e9 Вкладка «Организация»: вместо 5 полей доходов по годам — одна строка «Доход за 5 лет (суммарно)» + «Снижение уровня дохода», жирный заголовок убран; миграция старых файлов (сумма по годам → income_5y), авторасчёт/автозаполнение/DaData/шаблоны обновлены 2026-08-05 14:54:31 +04:00
LocoAgent 829a1a98e3 Отображение после загрузки проекта: доп. ОКВЭД нарезаются по кодам и нормализуются (не показываются «сырыми» склеенными записями), угрозы/инциденты с переносом внутри названия склеиваются в списке 2026-08-05 14:51:35 +04:00
LocoAgent ad54d3e0e2 Импорт: доп. ОКВЭД нарезаются по кодам (переносы внутри названий больше не рвут записи), угрозы с переносом внутри названия склеиваются (УБИ.209 «памяти\r\nядра процессора» → одна строка) 2026-08-05 14:46:39 +04:00
LocoAgent 9f8bf3999d Тех. меры: только при присвоенной категории и по факту СЗИ (без СЗИ нет «Регистрации и мониторинга»/«Антивируса»); промт СЗИ: при «без категории» — базовый минимум + провести категорирование, переданы характеристики объекта (интернет/ПО/архитектура); показатель 10 и 10.1–10.7: авторасчёт по методичке ФСТЭК (операции за год / дни) и порогам ПП-127 2026-08-05 14:39:03 +04:00
LocoAgent 07bf25ebc3 Обоснование ПП-127: висячий отступ — перенесённые строки «Показатель N. …» и текст расчёта с отступом, между показателями пустая строка (продолжение больше не выглядит отдельным пунктом) 2026-08-05 14:26:51 +04:00
LocoAgent 0b3ef66125 Окно авторасчёта ПП-127: результат больше не обрезается — окно растягиваемое (980x820, minsize, resizable), поле результата выше (12 строк) и со скроллбаром (показатели 8 и 9) 2026-08-05 14:17:57 +04:00
LocoAgent 1793182735 Показатель 8: если «Снижение уровня дохода» не заполнено — оценка ущерба «доход за год × 5» (наихудший сценарий), с пометкой в обосновании и окне авторасчёта ПП-127 2026-08-05 14:16:12 +04:00
LocoAgent 52b4ee6f1a Угрозы: список сортируется по возрастанию кода УБИ (УБИ.001 → УБИ.211) во всех местах — подбор программой, ИИ, автозаполнение, импорт, добавление недостающих 2026-08-05 14:14:06 +04:00
LocoAgent c9dffdd4ca ОКВЭД: полный справочник ОКВЭД2 (2770 записей) в едином формате «код название», resolve_okved без дефиса, импорт доп. ОКВЭД без переносов; угрозы: умный подбор — исключение неиспользуемых технологий (виртуализация, облако, мобильные, АСУ ТП, грид, суперкомпьютер, большие данные, беспроводные сети, контейнеры, smart-карты, ИИ), базовые угрозы локальных сетей 2026-08-05 14:08:53 +04:00
LocoAgent 136c58c948 fix: генерация циклов — вложенные циклы (показатели значимости внутри объекта КИИ) брали данные только из item; теперь используют общий словарь loops, документ больше не пустой 2026-08-05 13:52:29 +04:00
LocoAgent 28fa7edaa8 орг. меры: словарик заменён на текст заказчика (15 пунктов), автогенерация с учётом ответов по физической безопасности из опросника (да/нет), импорт ответов в модель 2026-08-05 13:39:24 +04:00
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
20 changed files with 5165 additions and 579 deletions
+5
View File
@@ -3,3 +3,8 @@ __pycache__/
.autosave/
Сгенерированные_документы/
*.result.docx
# Секреты и локальные настройки — НЕ коммитить
api_settings.json
.env
.env.local
+10 -4
View File
@@ -13,11 +13,17 @@ import os
import sys
import json
# ВНИМАНИЕ: реальные ключи НЕ хранятся в коде (утечка в git-историю — см. аудит).
# Источники ключей (по приоритету):
# 1) переменные окружения DADATA_TOKEN / DEEPSEEK_API_KEY / DEEPSEEK_MODEL / DEEPSEEK_BASE_URL
# 2) файл api_settings.json рядом с программой
# 3) DEFAULT_SETTINGS ниже (пустые значения)
DEFAULT_SETTINGS = {
'dadata_token': '431aa11706bf83f571cef0319259289aa750bcb1',
'ai_api_key': 'sk-3c7e6673f245412697d0e14b6096435f',
'ai_model': 'deepseek-chat',
'ai_base_url': 'https://api.deepseek.com',
'dadata_token': os.environ.get('DADATA_TOKEN', ''),
'ai_api_key': os.environ.get('DEEPSEEK_API_KEY', ''),
'ai_model': os.environ.get('DEEPSEEK_MODEL', 'deepseek-chat'),
'ai_base_url': os.environ.get('DEEPSEEK_BASE_URL', 'https://api.deepseek.com'),
}
# Старые ключи (для совместимости со старым файлом api_settings.json)
Binary file not shown.
@@ -16,19 +16,27 @@
],
"Наука": [
"1",
"11",
"13",
"7",
"8",
"9"
"9",
"11",
"13"
],
"Транспорт": [
"10",
"1",
"2",
"3",
"8",
"9",
"11",
"3"
"13",
"14"
],
"Связь": [
"4"
"4",
"6",
"8",
"9"
],
"Энергетика": [
"1",
@@ -61,6 +69,8 @@
"14"
],
"Банковская сфера и финансовый рынок": [
"8",
"9",
"10"
],
"Топливно-энергетический комплекс": [
@@ -82,20 +92,26 @@
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"11",
"12",
"13",
"14"
"13"
],
"Оборонная промышленность": [
"1",
"2",
"3",
"4",
"6",
"7",
"8",
"9",
"11",
"12",
"13"
],
"Ракетно-космическая промышленность": [
@@ -8,7 +8,7 @@
"6": "Организация сферы здравоохранения не является органом государственной власти, в связи с этим критерий неприменим.",
"7": "Организация сферы здравоохранения не является субъектом политической сферы деятельности, в связи с этим критерий неприменим.",
"8": "Организация сферы здравоохранения не является государственной корпорацией, государственным унитарным предприятием, государственной компанией, стратегическим акционерным обществом, стратегическим предприятием, в связи с этим критерий неприменим.",
"9": "Объект КИИ задействован только в предоставлении медицинских услуг, освобожденных от налогообложения, в связи с этим критерий неприменим.",
"9": "Объект КИИ задействован в предоставлении медицинских услуг. Показатель 9 применим ко всем субъектам КИИ; при расчёте учитывается, что медицинские услуги освобождены от налогообложения (значение показателя может быть минимальным).",
"10": "Организация сферы здравоохранения не является субъектом банковской сферы деятельности.",
"10.1": "Организация сферы здравоохранения не является центральным контрагентом.",
"10.2": "Организация сферы здравоохранения не является центральным депозитарием.",
File diff suppressed because it is too large Load Diff
+15 -7
View File
@@ -1,9 +1,17 @@
[
"Установление контролируемой зоны",
"Контроль физического доступа к объекту",
"Разработка документов (регламентов, инструкций, руководств) по обеспечению безопасности объекта",
"Назначение ответственных за обеспечение безопасности объекта",
"Обучение и инструктаж персонала",
"Регламентация действий при компьютерных инцидентах",
"Утверждение перечня лиц, имеющих доступ к объекту"
"Установлены границы контролируемой зоны.",
"Физическая охрана периметра обеспечена бетонным забором с колючей проволокой, исключающим прямой доступ к зданиям, и турникетом на проходной; въезд и вход осуществляются по пропускам с применением СКУД, ведется учет посетителей.",
"Имеется система видеонаблюдения.",
"Здания не имеют окон, выходящих во вне, входная группа оснащена металлической дверью.",
"Установлены охранно-пожарная сигнализация и тревожная кнопка.",
"Исключена возможность неконтролируемого пребывания посторонних лиц в помещениях, где ведется работа с объектами критической информационной инфраструктуры.",
"Назначены: ответственный за организацию обеспечения безопасности объектов критической информационной инфраструктуры, администратор и администратор безопасности объектов критической информационной инфраструктуры, утверждены инструкции, регламентирующие работу с данными и объектами критической информационной инфраструктуры.",
"Осуществляется внутренний контроль соответствия обеспечения безопасности объектов критической информационной инфраструктуры установленным требованиям.",
"Работники, непосредственно осуществляющие работу с объектами критической информационной инфраструктуры, ознакомлены с положениями законодательства Российской Федерации об объектах критической информационной инфраструктуры, документами, определяющими политику организации в отношении работы с объектами критической информационной инфраструктуры.",
"Обеспечивается учет машинных носителей объектов критической информационной инфраструктуры.",
"Обеспечивается резервирование и восстановление данных, модифицированных или уничтоженных вследствие несанкционированного доступа к ним.",
"Разработаны правила доступа к данным, обрабатываемым в объектах критической информационной инфраструктуры.",
"Определён перечень сотрудников, осуществляющих работу с объектами критической информационной инфраструктуры.",
"Ведётся учёт всех защищаемых носителей информации с помощью их маркировки и занесения учетных данных в журнал учета с отметкой об их выдаче (приеме).",
"Обеспечено раздельное хранение материальных носителей, обработка которых осуществляется в различных целях."
]
@@ -1,4 +1,4 @@
{
"system": "Ты — эксперт по безопасности критической информационной инфраструктуры (187-ФЗ, приказ ФСТЭК России № 239 и № 236). По категории значимости объекта КИИ, актуальным угрозам и уже выбранным средствам защиты информации (СЗИ) определи, каких средств не хватает, и дай рекомендации. Учитывай требования по категориям: для I категории — максимальный набор (антивирус, СКЗИ, SIEM, межсетевые экраны, средства анти-APT и т.д.), для II — расширенный набор, для III — базовый набор. Выбирай СЗИ ТОЛЬКО из реально существующих классов средств защиты (антивирусная защита, СКЗИ, межсетевой экран, SIEM, DLP, IDS/IPS, средства доверенной загрузки, контроля целостности, управления доступом и т.п.).",
"system": "Ты — эксперт по безопасности критической информационной инфраструктуры (187-ФЗ, приказ ФСТЭК России № 239 и № 236). По категории значимости объекта КИИ, характеристикам объекта, актуальным угрозам и уже выбранным средствам защиты информации (СЗИ) определи, каких средств не хватает, и дай рекомендации. Обязательно учитывай следующее:\n1) КАТЕГОРИЯ ЗНАЧИМОСТИ: если категория «без категории», «—», пусто или не присвоена — объект НЕ является значимым объектом КИИ, полный набор СЗИ по приказу № 239 для него НЕ требуется. В этом случае рекомендую только минимальный базовый набор (антивирусная защита, резервное копирование, управление доступом) и главный совет — провести категорирование объекта. НЕ перечисляй средства для I категории (СКЗИ, SIEM, анти-APT, доверенная загрузка и т.п.), если категория не присвоена. Для I категории — максимальный набор, для II — расширенный, для III — базовый набор по приказу № 239.\n2) ХАРАКТЕРИСТИКИ ОБЪЕКТА: внимательно читай переданные характеристики (наличие интернета, архитектура, ПО, тип объекта). НЕ рекомендуй СЗИ для технологий, которых нет у объекта: нет интернета/внешних сетей → не рекомендуй DDoS-защиту, VPN, веб-защиту, защиту от удалённых атак из интернета; нет виртуализации/контейнеров → не рекомендуй защиту виртуальной инфраструктуры и контейнеров; нет мобильных устройств → не рекомендуй защиту мобильных; объект не АСУ ТП → не рекомендуй защиту АСУ ТП; нет гостайны → не рекомендуй средства для гостайны. Отмечай только реально применимые классы СЗИ.\n3) ОБЪЁМ: максимум 5–7 рекомендаций, только самые важные для данной категории и объекта. Если текущий набор СЗИ достаточен — верни пустой список рекомендаций и «достаточно»: true.\nВыбирай СЗИ ТОЛЬКО из реально существующих классов средств защиты (антивирусная защита, СКЗИ, межсетевой экран, SIEM, DLP, IDS/IPS, средства доверенной загрузки, контроля целостности, управления доступом и т.п.).",
"response_format": "Верни ТОЛЬКО JSON: {\"рекомендации\": [{\"сзи\": \"название класса/средства\", \"зачем\": \"для чего нужно\"}], \"общие_советы\": [\"...\"], \"достаточно\": true|false}. Поле «достаточно» — true, если текущий набор СЗИ покрывает требования категории. Не придумывай конкретные торговые марки, если их нет в списке — указывай класс средства."
}
Binary file not shown.
Binary file not shown.
+10 -7
View File
@@ -310,10 +310,14 @@ def _subst_var_xml(xml: str, var: str, val: str) -> str:
+ '</w:r><w:r><w:t xml:space="preserve">')
def _expand_nested_loops(template: str, item: Dict) -> str:
def _expand_nested_loops(template: str, item: Dict, loops: Dict = None) -> str:
"""Разворачивает вложенные циклы вида <!-- loop:users_loop --> ... <!-- loop_end -->
данными из item (список словарей). Используется для циклов внутри цикла ИС,
например пользователи ИС. Переменные внутри: {{user_fio}}, {{user_position}}."""
данными из item (список словарей) или из общего словаря циклов loops.
Используется для циклов внутри цикла ИС, например пользователи ИС,
а также для вложенных таблиц (показатели значимости внутри объекта КИИ).
Переменные внутри: {{user_fio}}, {{user_position}}, {{item.*}}."""
if loops is None:
loops = {}
result = template
for _ in range(20):
m_start = LOOP_START.search(result)
@@ -321,6 +325,8 @@ def _expand_nested_loops(template: str, item: Dict) -> str:
break
key = m_start.group(1)
data = item.get(key)
if not isinstance(data, list):
data = loops.get(key)
if not isinstance(data, list):
# Нет данных для вложенного цикла — удаляем блок целиком
depth = 1
@@ -478,7 +484,7 @@ def expand_loops_in_zip(docx_path: str, loops: Dict[str, List[Dict]]) -> str:
clone = template_clean
# Вложенные циклы (users_loop и т.п.) — разворачиваем ДО подстановки
# обычных полей, чтобы {{user_fio}} внутри них не затёрлись алиасами
clone = _expand_nested_loops(clone, item)
clone = _expand_nested_loops(clone, item, loops)
for field, value in item.items():
if isinstance(value, list):
continue # списки — данные вложенных циклов, не подставляем
@@ -692,9 +698,6 @@ def _highlight_unmatched(doc):
pos = m.end()
if pos < len(full):
_add_run_with_tabs(para, full[pos:], first)
run = para.add_run(full[pos:])
if first is not None:
_copy_run_style(first, run)
for para in doc.paragraphs:
_process_para(para)
+1 -1
View File
@@ -83,7 +83,7 @@ def _generate_variables_reference(path):
_p(doc, 'Список переменных для шаблонов Word. Переменная подставляется в текст '
'как есть: {{имя_переменной}}. Циклы: <!-- loop:имя --> ... <!-- loop_end -->.')
groups = {'A': 'Общие переменные', 'B': '187-ФЗ (персональные данные)'}
groups = {'A': 'Общие переменные', 'B': '187-ФЗ (критическая информационная инфраструктура)'}
by_group = {'A': [], 'B': []}
for var, group, desc, _ in VARIABLE_DEFS:
by_group.setdefault(group, []).append((var, desc))
+434 -43
View File
@@ -7,48 +7,439 @@
],
"value_column": 1,
"fields": [
{"name": "Полное наименование организации", "var": "fullName", "cell": "", "keywords": "полное наименование организации"},
{"name": "Сокращённое наименование организации", "var": "shortName", "cell": "", "keywords": "сокращенное наименование организации"},
{"name": "Юридический адрес", "var": "addressLegal", "cell": "", "keywords": "адрес организации \\(юридический\\)"},
{"name": "Фактический адрес", "var": "addressActual", "cell": "", "keywords": "адрес организации \\(фактический\\)"},
{"name": "Электронная почта", "var": "email", "cell": "", "keywords": "электронная почта"},
{"name": "Телефон", "var": "phone", "cell": "", "keywords": "телефон"},
{"name": "ИНН", "var": "inn", "cell": "", "keywords": "инн"},
{"name": "ОГРН", "var": "ogrn", "cell": "", "keywords": "огрн"},
{"name": "Дата получения ОГРН", "var": "ogrnDate", "cell": "", "keywords": "дата получения огрн|дата.*огрн"},
{"name": "КПП", "var": "kpp", "cell": "", "keywords": "кпп"},
{"name": "Основной ОКВЭД", "var": "okved", "cell": "", "keywords": "основной оквэд"},
{"name": "Дополнительные ОКВЭД", "var": "okvedExtra", "cell": "", "keywords": "дополнительные оквэд"},
{"name": "Должность руководителя", "var": "chiefPosition", "cell": "", "keywords": "должность руководителя"},
{"name": "ФИО руководителя", "var": "chiefFio", "cell": "", "keywords": "фио руководителя"},
{"name": "Должность ответственного лица (безопасность ЗОКИИ)", "var": "responsiblePosition", "cell": "", "keywords": "должность ответственного лица"},
{"name": "ФИО ответственного лица (безопасность ЗОКИИ)", "var": "responsibleFio", "cell": "", "keywords": "фио ответственного лица"},
{"name": "Подразделение по безопасности ОКИИ", "var": "ispdnDepartment", "cell": "", "keywords": "структур.*подраздел"},
{"name": "Рабочий телефон ответственного", "var": "ispdnPhone", "cell": "", "keywords": "рабочий телефон ответственного|мобильный телефон ответственного"},
{"name": "Электронная почта ответственного", "var": "ispdnEmail", "cell": "", "keywords": "электронная почта ответственного"},
{"name": "Должность специалиста по ЗИ/администратора", "var": "administratorPosition", "cell": "", "keywords": "должность специалиста по защите информации|должность.*администрат"},
{"name": "ФИО специалиста по ЗИ/администратора", "var": "administratorFio", "cell": "", "keywords": "фио специалиста по защите информации|фио.*администрат"},
{"name": "Право владения зданием", "var": "buildingRight", "cell": "", "keywords": "право владения зданием"},
{"name": "Сумма прибыли за год", "var": "profit", "cell": "", "keywords": "сумма прибыли"},
{"name": "Сумма налога с прибыли", "var": "profitTax", "cell": "", "keywords": "сумма налога с прибыли"},
{"name": "Доменное имя и внешний IP", "var": "domainName", "cell": "", "keywords": "доменное имя и внешний сетевой адрес"},
{"name": "Сайт организации", "var": "site_name", "cell": "", "keywords": "сайт организации"},
{"name": "Должность ответственного за сайт", "var": "site_responsible_position", "cell": "", "keywords": "должность ответственного за сайт"},
{"name": "ФИО ответственного за сайт", "var": "site_responsible_fio", "cell": "", "keywords": "фио ответственного за сайт"},
{"name": "Разработчик сайта", "var": "site_service_provider", "cell": "", "keywords": "организации, которая разрабатывала и размещала корпоративный сайт"},
{"name": "ИНН разработчика сайта", "var": "site_service_provider_inn", "cell": "", "keywords": "инн организации, которая разрабатывала"},
{"name": "Размещение сайта", "var": "site_hosting", "cell": "", "keywords": "размещение сайта"},
{"name": "Адрес расположения серверов", "var": "site_hosting_address", "cell": "", "keywords": "адрес расположения серверов"},
{"name": "Облачная платформа", "var": "cloud_platform", "cell": "", "keywords": "облачную платформу"},
{"name": "Аренда выделенного сервера", "var": "cloud_dedicated_server", "cell": "", "keywords": "выделенный сервер"},
{"name": "Аренда виртуального ЦОД", "var": "cloud_vdc", "cell": "", "keywords": "виртуальный дата-центр"},
{"name": "Серверы в дата-центре", "var": "cloud_datacenter", "cell": "", "keywords": "сервера в дата-центре"},
{"name": "Средства контейнеризации", "var": "cloud_container", "cell": "", "keywords": "контейнеризации"},
{"name": "Собственная серверная (ЦОД)", "var": "cloud_own_server", "cell": "", "keywords": "собственная серверная"},
{"name": "Общее количество компьютеров", "var": "computers_total", "cell": "", "keywords": "общее количество компьютеров"},
{"name": "Компьютеры, подключённые к объектам КИИ", "var": "computers_kii", "cell": "", "keywords": "компьютеров подключенных к объектам кии"},
{"name": "Центр мониторинга", "var": "monitoring_center", "cell": "", "keywords": "к какому центру подключены"},
{"name": "Детали подключения к центру мониторинга", "var": "monitoring_details", "cell": "", "keywords": "детали подключения"},
{"name": "Дополнительная информация", "var": "additionalInfo", "cell": "", "keywords": "дополнительная информация"}
{
"name": "Полное наименование организации",
"var": "fullName",
"cell": "",
"keywords": "полное наименование организации"
},
{
"name": "Сокращённое наименование организации",
"var": "shortName",
"cell": "",
"keywords": "сокращенное наименование организации"
},
{
"name": "Юридический адрес",
"var": "addressLegal",
"cell": "",
"keywords": "адрес организации \\(юридический\\)"
},
{
"name": "Фактический адрес",
"var": "addressActual",
"cell": "",
"keywords": "адрес организации \\(фактический\\)"
},
{
"name": "Электронная почта",
"var": "email",
"cell": "",
"keywords": "электронная почта"
},
{
"name": "Телефон",
"var": "phone",
"cell": "",
"keywords": "телефон"
},
{
"name": "ИНН",
"var": "inn",
"cell": "",
"keywords": "инн"
},
{
"name": "ОГРН",
"var": "ogrn",
"cell": "",
"keywords": "огрн"
},
{
"name": "Дата получения ОГРН",
"var": "ogrnDate",
"cell": "",
"keywords": "дата получения огрн|дата.*огрн"
},
{
"name": "КПП",
"var": "kpp",
"cell": "",
"keywords": "кпп"
},
{
"name": "Основной ОКВЭД",
"var": "okved",
"cell": "",
"keywords": "основной оквэд"
},
{
"name": "Дополнительные ОКВЭД",
"var": "okvedExtra",
"cell": "",
"keywords": "дополнительные оквэд"
},
{
"name": "Должность руководителя",
"var": "chiefPosition",
"cell": "",
"keywords": "должность руководителя"
},
{
"name": "ФИО руководителя",
"var": "chiefFio",
"cell": "",
"keywords": "фио руководителя"
},
{
"name": "Должность ответственного лица (безопасность ЗОКИИ)",
"var": "responsiblePosition",
"cell": "",
"keywords": "должность ответственного лица"
},
{
"name": "ФИО ответственного лица (безопасность ЗОКИИ)",
"var": "responsibleFio",
"cell": "",
"keywords": "фио ответственного лица"
},
{
"name": "Подразделение по безопасности ОКИИ",
"var": "ispdnDepartment",
"cell": "",
"keywords": "структур.*подраздел"
},
{
"name": "Рабочий телефон ответственного",
"var": "ispdnPhone",
"cell": "",
"keywords": "рабочий телефон ответственного|мобильный телефон ответственного"
},
{
"name": "Электронная почта ответственного",
"var": "ispdnEmail",
"cell": "",
"keywords": "электронная почта ответственного"
},
{
"name": "Должность специалиста по ЗИ/администратора",
"var": "administratorPosition",
"cell": "",
"keywords": "должность специалиста по защите информации|должность.*администрат"
},
{
"name": "ФИО специалиста по ЗИ/администратора",
"var": "administratorFio",
"cell": "",
"keywords": "фио специалиста по защите информации|фио.*администрат"
},
{
"name": "Право владения зданием",
"var": "buildingRight",
"cell": "",
"keywords": "право владения зданием"
},
{
"name": "Сумма прибыли за год",
"var": "profit",
"cell": "",
"keywords": "сумма прибыли"
},
{
"name": "Сумма налога с прибыли",
"var": "profitTax",
"cell": "",
"keywords": "сумма налога с прибыли"
},
{
"name": "Доменное имя и внешний IP",
"var": "domainName",
"cell": "",
"keywords": "доменное имя и внешний сетевой адрес"
},
{
"name": "Сайт организации",
"var": "site_name",
"cell": "",
"keywords": "сайт организации"
},
{
"name": "Должность ответственного за сайт",
"var": "site_responsible_position",
"cell": "",
"keywords": "должность ответственного за сайт"
},
{
"name": "ФИО ответственного за сайт",
"var": "site_responsible_fio",
"cell": "",
"keywords": "фио ответственного за сайт"
},
{
"name": "Разработчик сайта",
"var": "site_service_provider",
"cell": "",
"keywords": "организации, которая разрабатывала и размещала корпоративный сайт"
},
{
"name": "ИНН разработчика сайта",
"var": "site_service_provider_inn",
"cell": "",
"keywords": "инн организации, которая разрабатывала"
},
{
"name": "Размещение сайта",
"var": "site_hosting",
"cell": "",
"keywords": "размещение сайта"
},
{
"name": "Адрес расположения серверов",
"var": "site_hosting_address",
"cell": "",
"keywords": "адрес расположения серверов"
},
{
"name": "Облачная платформа",
"var": "cloud_platform",
"cell": "",
"keywords": "облачную платформу"
},
{
"name": "Аренда выделенного сервера",
"var": "cloud_dedicated_server",
"cell": "",
"keywords": "выделенный сервер"
},
{
"name": "Аренда виртуального ЦОД",
"var": "cloud_vdc",
"cell": "",
"keywords": "виртуальный дата-центр"
},
{
"name": "Серверы в дата-центре",
"var": "cloud_datacenter",
"cell": "",
"keywords": "сервера в дата-центре"
},
{
"name": "Средства контейнеризации",
"var": "cloud_container",
"cell": "",
"keywords": "контейнеризации"
},
{
"name": "Собственная серверная (ЦОД)",
"var": "cloud_own_server",
"cell": "",
"keywords": "собственная серверная"
},
{
"name": "Общее количество компьютеров",
"var": "computers_total",
"cell": "",
"keywords": "общее количество компьютеров"
},
{
"name": "Компьютеры, подключённые к объектам КИИ",
"var": "computers_kii",
"cell": "",
"keywords": "компьютеров подключенных к объектам кии"
},
{
"name": "Центр мониторинга",
"var": "monitoring_center",
"cell": "",
"keywords": "к какому центру подключены"
},
{
"name": "Детали подключения к центру мониторинга",
"var": "monitoring_details",
"cell": "",
"keywords": "детали подключения"
},
{
"name": "Дополнительная информация",
"var": "additionalInfo",
"cell": "",
"keywords": "дополнительная информация"
},
{
"name": "Физическая охрана (ЧОП, сторожа, охранники)",
"var": "physicalGuard",
"cell": "",
"keywords": "физическая охрана"
},
{
"name": "Пожарная сигнализация",
"var": "physicalFireAlarm",
"cell": "",
"keywords": "пожарная сигнализация"
},
{
"name": "Тревожная кнопка",
"var": "physicalPanicButton",
"cell": "",
"keywords": "тревожная кнопка"
},
{
"name": "Решётки на окнах",
"var": "physicalBars",
"cell": "",
"keywords": "решетки на окнах"
},
{
"name": "Металлические двери",
"var": "physicalMetalDoors",
"cell": "",
"keywords": "металлические двери"
},
{
"name": "Система видеонаблюдения",
"var": "physicalVideoSurveillance",
"cell": "",
"keywords": "система видеонаблюдения"
},
{
"name": "Видеодомофон",
"var": "physicalVideoIntercom",
"cell": "",
"keywords": "видеодомофон"
},
{
"name": "Турникет на входе",
"var": "physicalTurnstile",
"cell": "",
"keywords": "турникет на входе"
},
{
"name": "Журнал учёта посетителей",
"var": "physicalVisitorLog",
"cell": "",
"keywords": "журнал учета посетителей|журнал учёта посетителей"
}
],
"criterion_fields": [
{
"num": "1",
"name": "Максимальное число пострадавших людей",
"keywords": "пострадавш",
"unit": "человек"
},
{
"num": "2б",
"name": "Люди с нарушенными условиями жизнедеятельности",
"keywords": "условия жизнедеятельности",
"unit": "тыс. человек"
},
{
"num": "3б",
"name": "Люди, для которых недоступны транспортные услуги",
"keywords": "транспортн",
"unit": "тыс. человек"
},
{
"num": "4а",
"name": "Абоненты, для которых недоступны услуги связи",
"keywords": "абонент",
"unit": "тыс. человек"
},
{
"num": "5а",
"name": "Максимальное время недоступности государственной услуги",
"keywords": "недоступна государственная услуга|недоступности государственной",
"unit": "часов"
},
{
"num": "5б",
"name": "Время, в течение которого госуслуга не может быть оказана",
"keywords": "регламентом|административным регламентом",
"unit": "%"
},
{
"num": "11б",
"name": "Люди, подверженные вредным воздействиям на окружающую среду",
"keywords": "вредным воздействиям",
"unit": "тыс. человек"
},
{
"num": "13а",
"name": "Снижение объёма продукции ГОЗ",
"keywords": "объем продукции|объём продукции",
"unit": "%"
},
{
"num": "13б",
"name": "Увеличение времени изготовления единицы продукции ГОЗ",
"keywords": "время изготовления",
"unit": "%"
},
{
"num": "14",
"name": "Максимальное время недоступности ИС (оборона)",
"keywords": "недоступна пользователю",
"unit": "часов"
},
{
"num": "2а",
"name": "Территория нарушения жизнеобеспечения населения",
"keywords": "территория.*жизнедеятельности|жизнедеятельности населения",
"unit": "вариант"
},
{
"num": "3а",
"name": "Территория нарушения транспортного сообщения",
"keywords": "территория.*транспорт",
"unit": "вариант"
},
{
"num": "3в",
"name": "Типы нарушенных перевозок и грузов",
"keywords": "типам нарушенных перевозок|типы нарушенных перевозок",
"unit": "вариант"
},
{
"num": "3г",
"name": "Категория объекта транспортной инфраструктуры",
"keywords": "категорированию объектов транспортной|категорию объекта транспортной",
"unit": "вариант"
},
{
"num": "4б",
"name": "Уровень органа власти, для которого недоступны услуги связи",
"keywords": "уровню органа государственной власти",
"unit": "вариант"
},
{
"num": "6",
"name": "Уровень государственного органа (невыполнение функции)",
"keywords": "функционирования государственного органа|государственного органа в части",
"unit": "вариант"
},
{
"num": "7",
"name": "Уровень международного договора",
"keywords": "международного договора",
"unit": "вариант"
},
{
"num": "11а",
"name": "Территория вредных воздействий на окружающую среду",
"keywords": "территория.*окружающую среду|окружающая среда может",
"unit": "вариант"
},
{
"num": "12",
"name": "Уровень пункта управления (ситуационного центра)",
"keywords": "пункта управления|ситуационного центра",
"unit": "вариант"
},
{
"num": "13.1",
"name": "Статус в кооперации головного исполнителя ГОЗ",
"keywords": "кооперации головного исполнителя|статусу в кооперации",
"unit": "вариант"
}
]
}
+38 -5
View File
@@ -97,13 +97,18 @@ def normalize_address(text: str) -> str:
return ''.join(out)
def clean_value(val):
"""Очистка и нормализация значения ячейки Excel."""
def clean_value(val, as_date=False):
"""Очистка и нормализация значения ячейки Excel.
as_date=True — конвертировать серийный номер Excel в дату (только для
полей-дат, напр. ogrnDate). Для финансовых полей НЕ вызывается,
иначе число 50 000 руб. превратится в «дату» (см. аудит importer.py:105-109).
"""
if val is None:
return ''
if isinstance(val, (int, float)):
# Попытка распознать дату (серийный номер Excel)
if 10000 < val < 80000:
# Попытка распознать дату (серийный номер Excel) — только для полей-дат
if as_date and 10000 < val < 80000:
serial = int(val)
if serial > 60:
serial -= 1
@@ -169,6 +174,21 @@ def import_187fz(filepath: str, log_fn: Optional[Callable] = None, settings_path
finally:
wb.close()
# Ответы раздела «Физическая безопасность» (да/нет) — собираем в словарь,
# чтобы текст организационных мер генерировался с учётом реальных средств.
ps_keys = [
'physicalGuard', 'physicalFireAlarm', 'physicalPanicButton',
'physicalBars', 'physicalMetalDoors', 'physicalVideoSurveillance',
'physicalVideoIntercom', 'physicalTurnstile', 'physicalVisitorLog',
]
ps = {}
for k in ps_keys:
v = str(data.get(k) or '').strip()
if v:
ps[k] = v
if ps:
data['physicalSecurity'] = ps
# Копирование полей ИСПДн в админа, если админ не заполнен
for src, dst in [('ispdnFio', 'administratorFio'), ('ispdnFio', 'adminFio'),
('ispdnPosition', 'administratorPosition'), ('ispdnPosition', 'adminPosition'),
@@ -253,7 +273,7 @@ def _import_187_common(wb, data, settings, log_fn):
if sn.lower() == sheet_part.lower():
target_ws = wb[sn]
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 key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'):
val = _extract_single_fio(val)
@@ -273,6 +293,8 @@ def _import_187_common(wb, data, settings, log_fn):
if val:
if key in ('chiefFio', 'responsibleFio', 'ispdnFio', 'adminFio'):
val = _extract_single_fio(val)
elif 'дата' in name.lower() or 'date' in key.lower():
val = clean_value(val, as_date=True)
data[key] = val
log_fn(f" {name} [по ключу]: {val}")
break
@@ -534,6 +556,17 @@ def _import_187_is_list(wb, data, settings, log_fn):
if _set(field_lower, 'tech_measures', val_b):
log_fn(f" Тех. меры: {val_b[:60]}")
continue
# --- Показатели критериев значимости (ПП-127) → criterion_input ---
# Строки вида «Показатель 1. …» / «Показатель 2а. …» / «Показатель 13.1. …»
m_crit = re.match(r'^показател[ья]?\s*(\d+(?:\.\d+)?)([а-яА-Я]?)', field_lower)
if m_crit:
_num_crit = m_crit.group(1) + m_crit.group(2)
# нормализация: «10.1» → «10.1», «13.1» → «13.1», «2а» → «2а»
_num_crit = _num_crit.replace(' .', '.').strip()
if val_b and 'выбрать' not in field_lower and not _is_hint(val_b):
is_obj.setdefault('criterion_input', {})[_num_crit] = str(val_b).strip()
log_fn(f" Показатель {_num_crit}: {str(val_b)[:60]}")
continue
if 'интернет' in field_lower:
is_obj['isInternet'] = is_checked
continue
+40 -2
View File
@@ -80,6 +80,12 @@ class InformationSystem:
org_measures: str = "" # Организационные меры (\n — разделитель)
tech_measures: str = "" # Технические меры (\n — разделитель)
# Введённые значения показателей ПП-127 для авторасчёта: {num: значение}
# num — «1», «2а», «2б», …, «14»; значение — число (количественные)
# или выбранный вариант (качественные). Заполняется окном авторасчёта,
# импортом из опросника или вручную; используется в _build_categorization_indicators.
criterion_input: Dict[str, str] = field(default_factory=dict)
@dataclass
class Company:
@@ -130,14 +136,27 @@ class Company:
profit_tax: str = "" # Сумма налога с прибыли за предыдущий год
# Показатель 8 (ущерб субъекту КИИ): доходы за 5 лет и суммарный ущерб
income_year_1: str = "" # Доход за 5 лет назад (руб.)
# В форме — одна строка «Доход за 5 лет (суммарно)»; по годам разбивает сам оператор.
income_5y: str = "" # Доход за 5 лет суммарно (руб.)
income_year_1: str = "" # Доход за 5 лет назад (руб.) — обратная совместимость
income_year_2: str = "" # Доход за 4 года назад (руб.)
income_year_3: str = "" # Доход за 3 года назад (руб.)
income_year_4: str = "" # Доход за 2 года назад (руб.)
income_year_5: str = "" # Доход за прошлый год (руб.)
income_loss: str = "" # Суммарный экономический ущерб от нарушения
income_loss: str = "" # Снижение уровня дохода (суммарный ущерб) от нарушения
# критических процессов (руб.) — ΔДоходов для п. 8
# Показатель 9: компоненты ΔБ (унификация автозаполнения и авторасчёта)
ndpi: str = "" # Снижение выплат от налога на добычу ПИ ΔНДПИ (руб.)
div: str = "" # Снижение выплат от дивидендов ΔДИВ (руб.)
vso: str = "" # Снижение выплат сторонним организациям ΔВСО (руб.)
ns: str = "" # Снижение сборов ΔНС (руб.)
# Показатель 10 (прекращение финансовых операций): операции за год (млн ед.)
ops_count: str = "" # Количество операций за прошедший год (млн ед.)
calendar_days: str = "" # Количество календарных дней в году (по умолчанию 365)
criterion10_value: str = "" # Значение показателя 10.1–10.7 (в своих единицах)
# Списки
commission: List[CommissionMember] = field(default_factory=list)
information_systems: List[InformationSystem] = field(default_factory=list)
@@ -146,6 +165,11 @@ class Company:
# Наименование структурного подразделения по безопасности
security_department: str = ""
# Ответы раздела «Физическая безопасность» опросника (да/нет):
# охрана, пожарная сигнализация, тревожная кнопка, решётки, металлические двери,
# видеонаблюдение, видеодомофон, турникет, журнал посетителей.
physical_security: Dict[str, str] = field(default_factory=dict)
def update_declensions(self, inflect_func):
"""Обновить склонения полного названия"""
if self.full_name:
@@ -208,6 +232,20 @@ class Company:
d["okved_extra"] = []
valid = {f.name for f in cls.__dataclass_fields__.values()}
obj = cls(**{k: v for k, v in d.items() if k in valid})
# Миграция: старые файлы с доходами по годам → суммарный доход за 5 лет
if not (getattr(obj, 'income_5y', '') or '').strip():
_sum = 0.0
_has = False
for i in range(1, 6):
v = str(getattr(obj, f'income_year_{i}', '') or '').strip()
if v:
try:
_sum += float(v.replace(' ', '').replace(',', '.').replace('\u00a0', ''))
_has = True
except Exception:
pass
if _has:
obj.income_5y = f"{_sum:,.0f}".replace(',', ' ')
# Если в поле ФИО несколько человек — оставить одного (старые опросники)
for fio_field in ('chief_fio', 'responsible_fio', 'admin_fio'):
val = getattr(obj, fio_field, '')
+95 -9
View File
@@ -11,6 +11,24 @@
import os
import json
import re
def _ubi_num(text):
"""Код УБИ из строки → int: «УБИ.069 Название» → 69. None, если кода нет."""
m = re.search(r'уби\.?\s*(\d+)', str(text), re.IGNORECASE)
return int(m.group(1)) if m else None
def sort_threats_by_ubi(items):
"""Сортировка угроз по возрастанию кода УБИ (УБИ.001, УБИ.002, …).
Строки без кода УБИ уходят в конец (по алфавиту).
"""
def _key(t):
n = _ubi_num(t)
return (0, n) if n is not None else (1, str(t).lower())
return sorted(items, key=_key)
def load_threats(dict_dir=None):
@@ -42,8 +60,26 @@ BASE_KEYWORDS = [
'вредоносн', 'несанкционированн', 'утечк', 'перехват', 'модификац',
'уничтожен', 'отказ в обслуживании', 'нсд', 'биос', 'пароль',
'криптографическ', 'шифрован', 'аутентификац',
# базовые угрозы локальных сетей / клиент-серверных архитектур
'доступ', 'данн', 'систем', 'конфигурац', 'целостност', 'информац',
'программн', 'пользовател', 'авторизац', 'сеанс', 'журнал', 'реестр',
'файл', 'память', 'буфер', 'ресурс', 'сервер', 'клиент', 'учётн',
'учетн', 'идентификац', 'привилег', 'средств', 'сетев', 'локальн',
]
# Технологии, которые по умолчанию НЕ используются на объекте.
# Если в характеристиках объекта есть явные упоминания — группа возвращается.
TECH_GROUPS = {
'виртуализац': ['виртуал', 'гипервизор', 'контейнер'],
'облако': ['облачн', 'облак'],
'мобильн': ['мобильн', 'смартфон', 'планшет'],
'грид': ['грид', 'суперкомпьютер'],
'большие данные': ['больших данных', 'хранилищ'],
'беспроводн': ['беспроводн', 'wi-fi', 'wifi'],
'smart-карты': ['smart-карт', 'смарт-карт'],
'машинное обучение': ['машинного обучения', 'искусственного интеллекта', 'нейросет'],
}
# Объект имеет подключение к сети Интернет / сеть электросвязи
INTERNET_KEYWORDS = [
'удалённ', 'удаленн', 'сетев', 'фишинг', 'ddos', 'отказ в обслуживании',
@@ -153,6 +189,21 @@ def suggest_threats_program(isys, threats=None, limit=None):
is_asu = _is_asu(isys)
is_network = _is_network(isys)
# --- Технологии, которые явно НЕ используются на объекте ---
# Если в характеристиках объекта (описание, архитектура, ПО, сфера) нет
# упоминаний технологии — угрозы этой группы исключаем.
tech_context = ' '.join([
str(getattr(isys, 'description', '') or ''),
str(getattr(isys, 'architecture', '') or ''),
str(getattr(isys, 'hw_sw', '') or ''),
str(getattr(isys, 'os_sw', '') or ''),
str(getattr(isys, 'app_sw', '') or ''),
str(getattr(isys, 'object_type', '') or ''),
str(getattr(isys, 'sphere', '') or ''),
str(getattr(isys, 'net_category', '') or ''),
str(getattr(isys, 'typical_object', '') or ''),
]).lower()
# --- Слова-исключения (если встретились в названии угрозы — она неактуальна) ---
exclude = []
@@ -167,6 +218,19 @@ def suggest_threats_program(isys, threats=None, limit=None):
if is_asu:
# АСУ: нет BIOS/виртуализации/браузера в привычном понимании
exclude += ['биос', 'грид', 'гипервизор', 'виртуальн', 'браузер']
else:
# Не АСУ — исключаем угрозы АСУ ТП / технологических процессов
exclude += ['технологическ', 'датчик', 'исполнительн', 'асутп', 'scada',
'промышленн', 'контроллер', 'числовым программным управлением',
'программируемых логических']
# Технологии, не упомянутые в характеристиках объекта → исключаем
for tech_name, kws in TECH_GROUPS.items():
if any(kw in tech_context for kw in kws):
continue # технология используется — угрозы оставляем
# Технология не упомянута — исключаем её угрозы
for kw in kws:
exclude.append(kw)
if not is_network and not is_asu:
# Обычная ИС: неактуальны чисто сетевые/телеком-угрозы без указания
@@ -198,9 +262,9 @@ def suggest_threats_program(isys, threats=None, limit=None):
sc = sum(1 for kw in pos if kw in tl)
scored.append((sc, t))
# Сортируем по релевантности (сначала — с наибольшим совпадением)
scored.sort(key=lambda x: -x[0])
result = [t for sc, t in scored if sc > 0]
# Сортируем по возрастанию кода УБИ (УБИ.001 → УБИ.211…)
scored = [t for sc, t in scored if sc > 0]
result = sort_threats_by_ubi(scored)
if limit and len(result) > limit:
result = result[:limit]
@@ -234,19 +298,18 @@ BASE_ORG_MEASURES = [
'Утверждение перечня лиц, имеющих доступ к объекту',
]
# Базовый набор тех. мер
# Базовый набор тех. мер — только универсальные, не требующие конкретных СЗИ.
# Меры, зависящие от средств защиты (антивирус, СЗИ от НСД, регистрация событий
# и т.п.), добавляются отдельно по факту наличия СЗИ (DEFENSE_TOOL_MEASURES).
BASE_TECH_MEASURES = [
'Идентификация и аутентификация пользователей',
'Управление доступом',
'Ограничение программной среды',
'Антивирусная защита',
'Защита информации от несанкционированного доступа (СЗИ от НСД)',
'Регистрация и мониторинг событий безопасности',
'Резервное копирование и восстановление',
'Защита технических средств и линий связи',
]
# Средства защиты → доп. тех. меры
# Средства защиты → доп. тех. меры (по факту наличия СЗИ у объекта)
DEFENSE_TOOL_MEASURES = [
('межсетев', 'Межсетевое экранирование'),
('firewall', 'Межсетевое экранирование'),
@@ -260,6 +323,23 @@ DEFENSE_TOOL_MEASURES = [
('виртуализ', 'Защита среды виртуализации'),
('vmware', 'Защита среды виртуализации'),
('hyper-v', 'Защита среды виртуализации'),
('антивирус', 'Антивирусная защита'),
('kaspersky', 'Антивирусная защита'),
('eset', 'Антивирусная защита'),
('dr.web', 'Антивирусная защита'),
('аваст', 'Антивирусная защита'),
('avast', 'Антивирусная защита'),
('сзи', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('от нсд', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('dallas', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('secret net', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('аккорд', 'Защита информации от несанкционированного доступа (СЗИ от НСД)'),
('siem', 'Регистрация и мониторинг событий безопасности'),
('dlp', 'Регистрация и мониторинг событий безопасности'),
('мониторинг', 'Регистрация и мониторинг событий безопасности'),
('регистрац', 'Регистрация и мониторинг событий безопасности'),
('доверенная загрузка', 'Контроль (доверенная) загрузки'),
('резервн', 'Резервное копирование и восстановление'),
]
@@ -331,8 +411,13 @@ def auto_fill_security(isys, threats=None):
if not (getattr(isys, 'org_measures', '') or '').strip():
res['org_measures'] = '\n'.join(BASE_ORG_MEASURES)
# 7. Тех. меры — базовые + по СЗИ объекта
# 7. Тех. меры — базовые + по СЗИ объекта.
# Только если объекту присвоена категория значимости (1/2/3):
# для незначимого объекта технические меры не требуются.
if not (getattr(isys, 'tech_measures', '') or '').strip():
cat = (getattr(isys, 'category', '') or '').strip()
has_category = cat in ('1', '2', '3')
if has_category:
tech = list(BASE_TECH_MEASURES)
defense = ' '.join(getattr(isys, 'defense_tools_list', []) or [])
dlow = defense.lower()
@@ -340,6 +425,7 @@ def auto_fill_security(isys, threats=None):
if kw in dlow and measure not in tech:
tech.append(measure)
res['tech_measures'] = '\n'.join(tech)
# else: категория не присвоена — технические меры не заполняем
return res
+890 -160
View File
File diff suppressed because it is too large Load Diff
+607 -2
View File
@@ -61,7 +61,8 @@ VARIABLE_DEFS = [
('{{company_okved_extra}}', 'A', 'Дополнительные ОКВЭД', 'company:okved_extra'),
('{{company_profit}}', 'A', 'Сумма прибыли за предыдущий год (руб.)', 'company:profit'),
('{{company_profit_tax}}', 'A', 'Сумма налога с прибыли (руб.)', 'company:profit_tax'),
('{{company_income_year_1}}', 'A', 'Доход за 5 лет назад (руб.) — показатель 8', 'company:income_year_1'),
('{{company_income_5y}}', 'A', 'Доход за 5 лет суммарно (руб.) — показатель 8', 'company:income_5y'),
('{{company_income_year_1}}', 'A', 'Доход за 5 лет назад (руб.) — показатель 8 (обратная совместимость)', 'company:income_year_1'),
('{{company_income_year_2}}', 'A', 'Доход за 4 года назад (руб.) — показатель 8', 'company:income_year_2'),
('{{company_income_year_3}}', 'A', 'Доход за 3 года назад (руб.) — показатель 8', 'company:income_year_3'),
('{{company_income_year_4}}', 'A', 'Доход за 2 года назад (руб.) — показатель 8', 'company:income_year_4'),
@@ -357,7 +358,10 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
'incidents': getattr(isys, 'incidents', '') or '',
'criteria_values': getattr(isys, 'criteria_values', '') or '',
'criteria_justification': getattr(isys, 'criteria_justification', '') or '',
'org_measures': getattr(isys, 'org_measures', '') or '',
# Орг. меры: если не заполнены вручную — автогенерация по опроснику
# (с учётом ответов раздела «Физическая безопасность»).
'org_measures': (getattr(isys, 'org_measures', '') or '')
or _build_org_measures_text(company, isys),
'tech_measures': getattr(isys, 'tech_measures', '') or '',
})
loops['information_systems'] = items
@@ -373,9 +377,610 @@ def _build_loop_data(company: Company, is_list: List) -> Dict[str, List[Dict]]:
})
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
# ============================================================
# ПОКАЗАТЕЛИ КРИТЕРИЕВ ЗНАЧИМОСТИ (ПП-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 лет: основное поле income_5y (суммарно), иначе — обратная совместимость по годам
inc5 = _num(getattr(company, 'income_5y', '') or '')
if inc5:
r_avg = inc5 / 5.0
else:
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':
# Фолбэк: ущерб не заполнен → оценка «доход за год × 5» (наихудший сценарий)
d_estimated = False
if d is None and r_avg:
d = r_avg * 5
d_estimated = True
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} руб. ")
if d_estimated:
text += (f"Снижение уровня дохода не заполнено, поэтому оценка ущерба принята по наихудшему "
f"сценарию: доход за год × 5 лет = {r_avg:,.2f} × 5 = {d:,.2f} руб. ")
else:
text += (f"В случае выхода из строя объекта КИИ потребуется замена, установка и настройка, "
f"стоимость которых составит около {d:,.2f} руб. ")
text += (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 _calc_criterion_8_opk(company, isys):
"""Показатель 8 (ущерб субъекту КИИ) по отраслевым особенностям ОПК
(ПП РФ 796 от 27.06.2026, п. 1820):
U_8 = U_у8 / R_год × 100%,
U_у8 = C + A + X,
где C суммарные затраты на объект КИИ;
A затраты на возмещение ущерба потерпевшим и их имуществу (если инцидент
не влечёт ущерба, A = 0);
X ущерб от ранее произошедших компьютерных инцидентов (если инцидентов
не было или нет данных, X = 0);
R_год усреднённый объём годового дохода за прошедший 5-летний период.
Значения C/A/X берутся из criterion_input (ключи 8_c, 8_a, 8_x);
если C не заполнен используется балансовая стоимость объекта КИИ.
Возвращает (текст_обоснования, категория или None).
"""
crit = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
c_val = _num(crit.get('8_c', '') or '') or _num(getattr(isys, 'balance_value', '') or '')
a_val = _num(crit.get('8_a', '') or '') or 0.0
x_val = _num(crit.get('8_x', '') or '') or 0.0
inc5 = _num(getattr(company, 'income_5y', '') or '')
if inc5:
r_avg = inc5 / 5.0
else:
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 c_val is None or not r_avg:
return ("Показатель применим (отраслевые особенности ОПК, ПП РФ № 796). "
"Для расчёта заполните суммарные затраты на объект КИИ (C) и доходы за 5 лет "
"(или балансовую стоимость объекта КИИ).", None)
u_y8 = c_val + a_val + x_val
val_pct = u_y8 / 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"Показатель 8 рассчитывается по отраслевым особенностям ОПК "
f"(ПП РФ № 796 от 27.06.2026): U_8 = U_у8 / R_год × 100%, U_у8 = C + A + X. "
f"Суммарные затраты на объект КИИ: C = {c_val:,.2f} руб. "
f"Затраты на возмещение ущерба потерпевшим: A = {a_val:,.2f} руб. "
f"Ущерб от ранее произошедших компьютерных инцидентов: X = {x_val:,.2f} руб. "
f"Ущерб субъекту КИИ: U_у8 = {c_val:,.2f} + {a_val:,.2f} + {x_val:,.2f} "
f"= {u_y8:,.2f} руб. "
f"Усреднённый годовой доход за 5-летний период: R_год = {r_avg:,.2f} руб. "
f"Показатель ущерба: U_8 = {u_y8:,.2f} / {r_avg:,.2f} × 100% = {val_pct:.6f} %.")
if cat:
text += (f" Так как {val_pct:.6f} % более 1% (пороговое значение для 3-й категории "
f"значимости), по данному показателю объекту присваивается {cat}-я категория значимости.")
else:
text += (f" Так как {val_pct:.6f} % менее 1% (пороговое значение для 3-й категории "
f"значимости), по данному показателю объекту категория значимости не присваивается.")
return text, cat
def _calc_criterion_13_opk(company, isys, num):
"""Показатели 13а/13б по отраслевым особенностям ОПК (ПП РФ № 796, п. 34, 38):
13а: V = (1 (V_max V_инц) / V_ГОЗ) × 100% (снижение объёма продукции ГОЗ)
13б: T = (1 (t_норм / t_ненорм)) × 100% (увеличение времени изготовления)
Значения берутся из criterion_input: 13a_vmax, 13a_vinc, 13a_vgoz;
13b_tnorm, 13b_tnonorm. Категория по порогам ПП-127 (CRITERION_QUANT_THRESHOLDS).
Возвращает (текст_обоснования, категория или None).
"""
crit = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
num = str(num)
if num == '13а':
vmax = _num(crit.get('13a_vmax', '') or '')
vinc = _num(crit.get('13a_vinc', '') or '')
vgoz = _num(crit.get('13a_vgoz', '') or '')
if vmax is None or vinc is None or not vgoz:
return ("Показатель 13а рассчитывается по отраслевым особенностям ОПК "
"(ПП РФ № 796): V = (1 (V_max V_инц) / V_ГОЗ) × 100%. "
"Заполните V_max, V_инц, V_ГОЗ (объёмы продукции ГОЗ).", None)
val = (1 - (vmax - vinc) / vgoz) * 100
formula = (f"V = (1 ({vmax:,.2f} {vinc:,.2f}) / {vgoz:,.2f}) × 100% = {val:.6f} %")
else: # 13б
tnorm = _num(crit.get('13b_tnorm', '') or '')
tnon = _num(crit.get('13b_tnonorm', '') or '')
if tnorm is None or tnon is None or tnon <= 0:
return ("Показатель 13б рассчитывается по отраслевым особенностям ОПК "
"(ПП РФ № 796): T = (1 − (t_норм / t_ненорм)) × 100%. "
"Заполните t_норм и t_ненорм (время операций по ГОЗ).", None)
val = (1 - tnorm / tnon) * 100
formula = (f"T = (1 ({tnorm:,.2f} / {tnon:,.2f})) × 100% = {val:.6f} %")
cat = _criterion_other_cat(num, val)
text = (f"Показатель {num} рассчитывается по отраслевым особенностям ОПК "
f"(ПП РФ № 796 от 27.06.2026): {formula}.")
if cat:
text += (f" По порогам ПП-127 это соответствует {cat}-й категории значимости.")
else:
text += " По порогам ПП-127 категория значимости не присваивается."
return text, cat
# Пороги показателя 10 и подпоказателей 10.1–10.7 (ПП-127, «Значение показателя»)
CRITERION_10_THRESHOLDS = {
# num: (категория → (нижняя граница, верхняя граница)), границы «включительно»
# Показатель 10 — среднедневное количество операций (млн ед.):
# III — менее или равно 70, II — более 70 но менее или равно 120, I — более 120.
'10': {'I': (120, None), 'II': (70, 120), 'III': (None, 70)},
'10.1': {'I': (10, None), 'II': (1, 10), 'III': (None, 1)}, # трлн руб.
'10.2': {'I': (25, None), 'II': (10, 25), 'III': (None, 10)}, # тыс. шт.
'10.3': {'I': (2000, None), 'II': (1000, 2000), 'III': (50, 1000)}, # млрд руб.
'10.4': {'I': (5000, None), 'II': (1500, 5000), 'III': (100, 1500)}, # млрд руб.
'10.5': {'I': (150, None), 'II': (100, 150), 'III': (25, 100)}, # договоров
'10.6': {'I': (5000, None), 'II': (1000, 5000), 'III': (100, 1000)}, # млрд руб.
'10.7': {'I': (500, None), 'II': (200, 500), 'III': (30, 200)}, # млн ед.
}
def _criterion_10_cat(num, value):
"""Категория значимости по показателю 10 / 10.x (ПП-127).
value численное значение показателя. Возвращает 'I'/'II'/'III' или None.
"""
thr = CRITERION_10_THRESHOLDS.get(str(num))
if not thr or value is None:
return None
for cat in ('I', 'II', 'III'):
lo, hi = thr[cat]
if lo is not None and value < lo:
continue
if hi is not None and value > hi:
continue
return cat
return None
def _calc_criterion_10(company, isys, num):
"""Расчёт показателя 10 (прекращение финансовых операций) и подпоказателей 10.1–10.7.
Показатель 10: среднедневное количество операций = операции за год (млн ед.) / дни.
Подпоказатели 10.x: значение показателя (в своих единицах) сравнивается с порогами.
Возвращает (текст_обоснования, категория или None).
"""
num = str(num)
if num not in CRITERION_10_THRESHOLDS:
return None, None
if num == '10':
ops = _num(getattr(company, 'ops_count', '') or '')
days = _num(getattr(company, 'calendar_days', '') or '')
if days is None or days <= 0:
days = 365
if ops is None:
return ("Показатель применим. Для расчёта заполните данные организации: "
"«Количество операций за прошедший год (млн ед.)».", None)
val = ops / days
cat = _criterion_10_cat('10', val)
text = (f"Показатель прекращения финансовых операций оценивается среднедневным количеством "
f"операций, проведенных клиентами по банковским счетам и (или) без открытия банковского "
f"счета. Количество операций за прошедший год: {ops:,.2f} млн ед. Количество календарных "
f"дней в году: {days:.0f}. Среднедневное количество операций: {ops:,.2f} / {days:.0f} "
f"= {val:.6f} млн ед./день.")
else:
value = _num(getattr(company, 'criterion10_value', '') or '')
if value is None:
return (f"Показатель применим. Для расчёта заполните значение показателя "
f"«{num}» в данных организации.", None)
val = value
cat = _criterion_10_cat(num, val)
text = (f"Показатель {num} оценивается значением показателя: {val:,.6f} "
f"(в единицах, установленных ПП-127).")
if cat:
text += (f" Согласно Постановлению № 127 полученное значение соответствует "
f"{cat}-й категории значимости, по данному показателю объекту присваивается "
f"{cat}-я категория значимости.")
else:
text += (" Значение не достигает порога III категории — по данному показателю "
"категория значимости не присваивается.")
return text, cat
# Пороги количественных показателей 1–7, 11–14 (ПП-127), кроме 8/9/10:
# num → [(категория, lo, lo_incl, hi, hi_incl)] — значение соответствует, если
# (lo is None или value > lo при not lo_incl / value >= lo при lo_incl) и
# (hi is None или value < hi при not hi_incl / value <= hi при hi_incl).
CRITERION_QUANT_THRESHOLDS = {
'1': [('III', 1, True, 50, True), ('II', 50, False, 500, True), ('I', 500, False, None, False)], # человек
'2б': [('III', 2, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. чел.
'3б': [('III', 2, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. чел.
'4а': [('III', 3, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. абонентов
'5а': [('III', 12, False, 24, True), ('II', 6, False, 12, True), ('I', None, False, 6, True)], # часов
'5б': [('III', None, False, 30, True), ('II', 30, False, 70, True), ('I', 70, False, None, False)], # %
'11б': [('III', 2, True, 1000, False), ('II', 1000, True, 5000, False), ('I', 5000, True, None, False)], # тыс. чел.
'13а': [('III', 0, False, 10, True), ('II', 10, False, 15, True), ('I', 15, False, None, False)], # %
'13б': [('III', 0, False, 10, True), ('II', 10, False, 40, True), ('I', 40, False, None, False)], # %
'14': [('III', 2, False, 4, True), ('II', 1, False, 2, True), ('I', None, False, 1, True)], # часов
}
# Качественные показатели (выбор одного из вариантов III/II/I): 2а, 3а, 3в, 3г, 4б, 6, 7, 11а, 12, 13.1
CRITERION_CHOICE_INDICATORS = {'2а', '3а', '', '3г', '4б', '6', '7', '11а', '12', '13.1'}
# Единицы измерения количественных показателей (для подписи в окне авторасчёта)
CRITERION_QUANT_UNITS = {
'1': 'человек', '2б': 'тыс. человек', '3б': 'тыс. человек', '4а': 'тыс. абонентов',
'5а': 'часов', '5б': '%', '11б': 'тыс. человек', '13а': '%', '13б': '%', '14': 'часов',
}
def _criterion_other_cat(num, value):
"""Категория количественного показателя (кроме 8/9/10) по значению (ПП-127)."""
rules = CRITERION_QUANT_THRESHOLDS.get(str(num))
if not rules or value is None:
return None
for cat, lo, lo_incl, hi, hi_incl in rules:
ok = True
if lo is not None:
ok = ok and ((value >= lo) if lo_incl else (value > lo))
if hi is not None:
ok = ok and ((value <= hi) if hi_incl else (value < hi))
if ok:
return cat
return None
def _calc_criterion_other(company, isys, num, value=None, choice=None):
"""Расчёт остальных показателей критериев значимости (1–7, 11–14).
Количественные (1, 2б, 3б, 4а, 5а, 5б, 11б, 13а, 13б, 14): value число,
категория по порогам ПП-127 (CRITERION_QUANT_THRESHOLDS).
Качественные (2а, 3а, 3в, 3г, 4б, 6, 7, 11а, 12, 13.1): choice текст
выбранного варианта из criteria_127.json (values), категория его ключ.
Возвращает (текст_обоснования, категория или None).
"""
num = str(num)
if num in CRITERION_CHOICE_INDICATORS:
# Качественный показатель: выбираем один из вариантов III/II/I
criteria = _load_json('criteria_127.json')
for grp in (criteria if isinstance(criteria, list) else []):
for ind in grp.get('indicators', []):
if str(ind.get('num', '')) == num:
vals = ind.get('values', {}) or {}
if not choice:
return (f"Показатель применим. Для расчёта выберите один из вариантов "
f"(III/II/I категория) в окне авторасчёта.", None)
cat = None
for c, txt in vals.items():
if txt and str(txt).strip() == str(choice).strip():
cat = c
break
if not cat:
return (f"Показатель применим. Выбранный вариант не найден в перечне значений.", None)
text = (f"Показатель {num}: {choice}. Согласно Постановлению № 127 данному значению "
f"соответствует {cat}-я категория значимости.")
return text, cat
return (f"Показатель применим. Варианты значений не найдены в справочнике.", None)
# Количественный показатель
if value is None:
unit = CRITERION_QUANT_UNITS.get(num, '')
return (f"Показатель применим. Для расчёта введите значение показателя"
+ (f" ({unit})" if unit else "") + ".", None)
cat = _criterion_other_cat(num, value)
unit = CRITERION_QUANT_UNITS.get(num, '')
text = (f"Показатель {num}: значение показателя — {value:,.6f} {unit}. "
f"Согласно Постановлению № 127 ")
if cat:
text += (f"данному значению соответствует {cat}-я категория значимости.")
else:
text += ("значение не достигает порога III категории — категория значимости не присваивается.")
return text, cat
def _build_org_measures_text(company, isys=None):
"""Формирует текст организационных мер из словаря org_measures.json
с учётом ответов раздела «Физическая безопасность» опросника.
Если в опроснике указано «нет» (охрана, сигнализация, тревожная кнопка,
видеонаблюдение, металлические двери и т.п.) соответствующие пункты
из текста убираются. Пункты, не привязанные к средствам, остаются всегда.
"""
import os as _os
items = _load_json('org_measures.json')
if not isinstance(items, list):
items = []
ps = getattr(company, 'physical_security', None) or {}
def _no(*keys):
"""True, если хотя бы по одному ключу явно указано «нет»/«не имеется»."""
for k in keys:
v = str(ps.get(k) or '').strip().lower()
if v in ('нет', 'не имеется', 'нет,', '0', 'false'):
return True
return False
rules = [
# (подстрока пункта, ключи physical_security, при которых пункт УБИРАЕМ)
('Физическая охрана периметра', ('physicalGuard', 'physicalTurnstile', 'physicalVisitorLog')),
('Имеется система видеонаблюдения', ('physicalVideoSurveillance',)),
('Здания не имеют окон', ('physicalMetalDoors',)),
('охранно-пожарная сигнализация и тревожная кнопка', ('physicalFireAlarm', 'physicalPanicButton')),
('охранно-пожарная сигнализация', ('physicalFireAlarm',)),
('тревожная кнопка', ('physicalPanicButton',)),
('турникетом на проходной', ('physicalTurnstile',)),
('СКУД', ('physicalTurnstile',)),
('учет посетителей', ('physicalVisitorLog',)),
]
out = []
for line in items:
line = str(line).strip()
if not line:
continue
drop = False
for sub, keys in rules:
if sub in line and _no(*keys):
drop = True
break
if not drop:
out.append(line)
return '\n'.join(out) if out else ''
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 = []
first_cat_num = None # номер показателя, по которому объект отнесён к I категории (п. 6 ПП-127)
# Введённые значения показателей (из опросника/окна авторасчёта): {num: значение}
crit_in = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
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 first_cat_num is not None:
# Остановка расчёта: объект уже отнесён к I категории по одному из показателей
# (п. 6 Правил, утв. ПП РФ № 127: «расчет по остальным показателям не проводится»).
if applicable:
value = ''
justification = (f"Расчёт по данному показателю не проводится: объект отнесён к "
f"I категории значимости по показателю {first_cat_num} "
f"(п. 6 Правил категорирования, утв. ПП РФ № 127).")
category = ''
else:
value = 'Показатель не применим.'
justification = (just.get(num) or just.get(grp_num)
or f"Показатель не применим к объекту КИИ организации сферы «{sphere or ''}».")
elif not applicable:
value = 'Показатель не применим.'
justification = (just.get(num) or just.get(grp_num)
or f"Показатель не применим к объекту КИИ организации сферы «{sphere or ''}».")
elif grp_num in ('8', '9'):
if grp_num == '8' and sphere == 'Оборонная промышленность':
# ОПК: U_8 = (C+A+X)/R_год×100% (ПП РФ № 796, п. 1820)
text, cat = _calc_criterion_8_opk(company, isys)
else:
text, cat = _calc_criterion_8_9(company, isys, grp_num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
elif grp_num == '10':
text, cat = _calc_criterion_10(company, isys, num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
elif num in ('13а', '13б') and sphere == 'Оборонная промышленность':
# ОПК: V = (1(V_maxV_инц)/V_ГОЗ)×100%, T = (1(t_норм/t_ненорм))×100%
# (ПП РФ № 796, п. 34, 38) — при наличии введённых составляющих
_opk_crit = dict(getattr(isys, 'criterion_input', None) or {}) if isys else {}
_opk_ready = bool(_opk_crit.get('13a_vmax', '') or _opk_crit.get('13a_vinc', '') or _opk_crit.get('13a_vgoz', '') or _opk_crit.get('13b_tnorm', '') or _opk_crit.get('13b_tnonorm', ''))
if _opk_ready:
text, cat = _calc_criterion_13_opk(company, isys, num)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
else:
value = ''
justification = (just.get(num) or just.get(grp_num)
or 'Показатель применим к объекту КИИ; значение определяется по данным объекта (для ОПК — по формулам ПП РФ № 796: V = (1(V_maxV_инц)/V_ГОЗ)×100%, T = (1(t_норм/t_ненорм))×100%).')
else:
# Применимый показатель без формульного авторасчёта (1–7, 11–14):
# если значение введено (опросник/окно авторасчёта) — считаем категорию по порогам
if num in crit_in and str(crit_in.get(num, '')).strip():
raw = str(crit_in.get(num, '')).strip()
# Число или выбранный вариант (качественный)
try:
_v = float(raw.replace(' ', '').replace(',', '.').replace('\u00a0', ''))
text, cat = _calc_criterion_other(company, isys, num, value=_v)
except ValueError:
text, cat = _calc_criterion_other(company, isys, num, choice=raw)
if text:
value = text
if cat:
category = f"{cat}-я категория"
else:
category = ''
else:
value = ''
justification = (just.get(num) or just.get(grp_num)
or 'Показатель применим к объекту КИИ; значение определяется по данным объекта.')
# Если по показателю присвоена I категория — фиксируем для остановки расчёта
# (точное совпадение «I-я категория», а не startswith('I'), иначе ловится II/III)
if first_cat_num is None and category.startswith('I-я'):
first_cat_num = num
rows.append({
'section': section,
'name': name,
'number': num,
'value': value,
'justification': justification,
'category': category,
})
return rows
# ============================================================
# РЕЗОЛВЕР ЗНАЧЕНИЙ
# ============================================================
+175 -13
View File
@@ -161,15 +161,20 @@ def normalize_address(text):
def resolve_okved(code, dadata_token=None):
"""Код ОКВЭД → «код - название».
"""Код ОКВЭД → «код название» (без дефиса), например «27.32.2 Производство силовых кабелей».
Принимает и голый код («27.32.2»), и строку с названием
(«27.32.2 - Производство силовых кабелей» / «27.32.2 Производство силовых кабелей»).
1) Ищем в словаре dictionaries/okved.json (строки вида «27.32.2 Название»).
2) Если не нашли запрашиваем DaData suggest okved2 (если есть токен).
3) Иначе возвращаем код как есть.
3) Иначе возвращаем нормализованную строку «код название» как есть.
"""
code = (code or '').strip()
if not code:
return ''
# Извлекаем код из входной строки: «27.32.2 - Производство силовых кабелей» → «27.32.2»
m = re.match(r'^\s*(\d+(?:\.\d+)*)', code)
code_only = m.group(1) if m else code.split()[0].rstrip('.')
# 1) Словарь
try:
import os, json
@@ -178,12 +183,8 @@ def resolve_okved(code, dadata_token=None):
items = json.load(f)
for item in items:
item = str(item).strip()
# совпадение по началу: «27.32.2» или «27.32.2 - ...» / «27.32.2 ...»
if item.split()[0].rstrip('.') == code.rstrip('.'):
# нормализуем: «код - название»
rest = item[len(item.split()[0]):].strip().lstrip('- ').strip()
if rest:
return f"{code} - {rest}"
# совпадение по коду: «27.32.2» или «27.32.2 Название»
if item.split()[0].rstrip('.') == code_only.rstrip('.'):
return item
except Exception:
pass
@@ -191,7 +192,7 @@ def resolve_okved(code, dadata_token=None):
if dadata_token:
try:
js, err = _http_json('https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/okved2',
data={'query': code, 'count': 1},
data={'query': code_only, 'count': 1},
headers={'Content-Type': 'application/json',
'Authorization': f'Token {dadata_token}'})
if not err and js:
@@ -200,7 +201,12 @@ def resolve_okved(code, dadata_token=None):
return sugg[0]['value']
except Exception:
pass
return code
# 3) Нормализуем вход: «27.32.2 - Название» → «27.32.2 Название» (без дефиса)
rest = code[len(m.group(1)):].strip() if m else ''
rest = rest.lstrip('- ').strip()
if rest:
return f"{code_only} {rest}"
return code_only
def _fmt_date(value):
@@ -340,8 +346,15 @@ def check_company_dadata(inn, token):
# ОКВЭД: основной (okved) и дополнительные (okveds — список словарей с 'code')
okved = d.get('okved', '') or ''
okved_extra = []
for ok in (d.get('okveds') or []):
code = (ok or {}).get('code') or ''
raw_okveds = d.get('okveds') 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:
okved_extra.append(code)
@@ -518,6 +531,111 @@ def compare_company_values(old_data, new_data):
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 normalize_threats(text):
"""Склеить переносы внутри названий угроз.
В опросниках встречается «УБИ.209 Угроза памяти\r\nядра процессора»
перенос строки внутри одного названия. Строка, не начинающаяся с «УБИ.»,
считается продолжением предыдущей угрозы.
"""
if not text:
return ''
lines = [ln.strip() for ln in str(text).split('\n') if ln.strip()]
out = []
for ln in lines:
if re.match(r'^уби\.?\s*\d+', ln, re.IGNORECASE):
out.append(ln)
elif out:
out[-1] = out[-1] + ' ' + ln
else:
out.append(ln)
return '\n'.join(out)
def split_okved_extra(value):
"""Разбить okvedExtra (строка или список строк с переносами) на записи «код название».
В опросниках доп. ОКВЭД часто идут склеенными:
«25.93 Производство изделий из проволоки\nцепей и пружин 32.12.1 »
Здесь «цепей и пружин» продолжение названия 25.93, а 32.12.1 новый код.
Нарезаем по кодам ОКВЭД (двузначный код с точками в начале записи).
"""
if isinstance(value, (list, tuple)):
text = ' '.join(str(x) for x in value)
else:
text = str(value or '')
text = text.replace('\r', ' ').replace('\n', ' ')
# Нарезаем по началу кода ОКВЭД: «25.93», «32.12.1», «43.2» …
# Перед кодом не должно быть цифры или точки (чтобы не разрезать «25.93» на «25» и «93»)
parts = re.split(r'(?<!\d)(?<!\.)(?=\d{2}(?:\.\d+)*\b)', text)
out = []
for p in parts:
p = p.strip().strip('-—– ').strip()
if p and re.match(r'^\d{2}(?:\.\d+)*', p):
out.append(p)
return out
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,
threats=None):
"""ИИ предлагает актуальные угрозы БИ для объекта КИИ по его характеристикам.
@@ -564,9 +682,52 @@ def suggest_threats_ai(security_data, api_key, model='deepseek-chat', base_url=N
if isinstance(threats_out, str):
threats_out = [threats_out]
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
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,
typical_objects=None, spheres=None, object_types=None,
architectures=None):
@@ -694,7 +855,8 @@ def check_company_ai(company_data, api_key, model='deepseek-chat', base_url=None
if not api_key:
return None, "Не задан API-ключ ИИ (Файл → Настройки → API)"
system = (
"Ты — юрист по защите персональных данных (187-ФЗ). Проверь реквизиты организации. "
"Ты — специалист по проверке реквизитов организации (ИНН/КПП/ОГРН). "
"Проверь реквизиты организации. "
"Верни ТОЛЬКО JSON: "
'{"оценка": "ok|warning|error", '
'"замечания": ["..."], '
+8 -8
View File
@@ -30,8 +30,8 @@ def create_test_template():
doc.add_paragraph('<!-- loop:information_systems -->')
doc.add_paragraph('=== IS: {{item.name}} ===')
doc.add_paragraph('Category: {{item.category}}')
doc.add_paragraph('Threat type: {{item.threat_type}}')
doc.add_paragraph('Defence level: {{item.defence_level}}')
doc.add_paragraph('Sphere: {{item.sphere}}')
doc.add_paragraph('Object type: {{item.object_type}}')
doc.add_paragraph('')
# Inner loop: commission
@@ -72,12 +72,12 @@ def test_zip_loop_expansion():
CommissionMember(role='Член комиссии', position='Бухгалтер', fio='Сидорова А.А.'),
],
information_systems=[
InformationSystem(name='ИС-1 "Бухгалтерия"', category='специальные',
pd_count='менее 100 000', threat_type='1', defence_level='2'),
InformationSystem(name='ИС-2 "Кадры"', category='иные',
pd_count='более 100 000', threat_type='3', defence_level='4'),
InformationSystem(name='ИС-3 "Склад"', category='общедоступные',
pd_count='менее 100 000', threat_type='2', defence_level='3'),
InformationSystem(name='ОКИИ-1 "Платёжный шлюз"', category='1',
sphere='Банковская сфера и финансовый рынок', object_type='АСУ'),
InformationSystem(name='ОКИИ-2 "Кадры"', category='2',
sphere='Наука', object_type='ИС'),
InformationSystem(name='ОКИИ-3 "Склад"', category='3',
sphere='Здравоохранение', object_type='ИС'),
]
)