""" Журнал действий ИИ-агентов «iss-ledger-117»: записи только добавляются и связаны хеш-цепочкой по ГОСТ Р 34.11-2012 («Стрибог-256»). Назначение — протоколирование обращений к компонентам ИИ с контролем целостности, которого ожидает приказ ФСТЭК России № 117 (утверждён 11.04.2025, действует с 01.03.2026). Соответствия приказу пакет сам по себе не даёт и не удостоверяет. Граница: цепочка обнаруживает изменение, удаление, вставку и перестановку отдельных записей; полную перезапись файла его владельцем она не обнаруживает — для этого голову цепочки закрепляют у независимой стороны. До версии 1.1.0 здесь стояли номера пунктов приказа (14.8, 18.2, 21.4) и реквизиты закона № 243-ФЗ, которых в первоисточниках нет, — сняты. """ import json import os from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple, Union from .gost_streebog import streebog256_hex GENESIS_PREV_HASH = "0" * 64 def _canonical_string( index: int, timestamp: str, agent_id: str, action: str, target: str, payload_hash: str, verdict: str, reason: str, prev_hash: str, ) -> str: """Формирует каноническую строку для расчета хэша блока по ГОСТ Р 34.11-2012.""" return f"{index}|{timestamp}|{agent_id}|{action}|{target}|{payload_hash}|{verdict}|{reason}|{prev_hash}" class IssLedger117: """ Класс управления неизменяемым криптографическим журналом. Каждая запись криптографически связывается с предыдущей по цепочке ГОСТ Стрибог-256. """ def __init__(self, filepath: str = "audit_fstec117.jsonl"): self.filepath = os.path.abspath(filepath) self._ensure_file_exists() def _ensure_file_exists(self) -> None: """Создает файл журнала и родительскую директорию, если они отсутствуют.""" directory = os.path.dirname(self.filepath) if directory and not os.path.exists(directory): os.makedirs(directory, exist_ok=True) if not os.path.exists(self.filepath): with open(self.filepath, "w", encoding="utf-8") as f: pass # Создаем пустой файл def _get_last_entry(self) -> Optional[Dict[str, Any]]: """Возвращает последнюю запись из журнала или None.""" if not os.path.exists(self.filepath) or os.path.getsize(self.filepath) == 0: return None last_line = None with open(self.filepath, "r", encoding="utf-8") as f: for line in f: line_str = line.strip() if line_str: last_line = line_str if last_line: return json.loads(last_line) return None def append( self, agent_id: str, action: str, target: str, verdict: str, reason: str = "OK", payload: Union[str, bytes, Dict[str, Any]] = "", ) -> Dict[str, Any]: """ Добавляет новое событие в журнал с криптографическим связыванием по ГОСТ. Параметры: - agent_id: идентификатор агента (например, 'claude-pk1', 'sentinel-proxy') - action: тип действия ('tool_call:bash', 'tool_call:sql', 'prompt', etc.) - target: объект воздействия ('/etc/nginx', 'db.prod.users', 'api.gateway') - verdict: решение безопасности ('PERMIT', 'BLOCK', 'AUDIT') - reason: обоснование (при BLOCK — причина отказа) - payload: тело запроса/параметры (вычисляется хэш) """ verdict = verdict.upper() if verdict not in ("PERMIT", "BLOCK", "AUDIT"): raise ValueError(f"Недопустимый вердикт: {verdict}. Ожидается PERMIT, BLOCK или AUDIT.") # Вычисляем хэш полезной нагрузки по Стрибог-256 if isinstance(payload, (dict, list)): payload_bytes = json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") elif isinstance(payload, str): payload_bytes = payload.encode("utf-8") else: payload_bytes = bytes(payload) payload_hash = streebog256_hex(payload_bytes) # Получаем данные последней записи last_entry = self._get_last_entry() if last_entry is None: index = 1 prev_hash = GENESIS_PREV_HASH else: index = last_entry["index"] + 1 prev_hash = last_entry["entry_hash"] timestamp = datetime.now(timezone.utc).isoformat() canonical_str = _canonical_string( index=index, timestamp=timestamp, agent_id=agent_id, action=action, target=target, payload_hash=payload_hash, verdict=verdict, reason=reason, prev_hash=prev_hash, ) entry_hash = streebog256_hex(canonical_str.encode("utf-8")) entry = { "index": index, "timestamp": timestamp, "agent_id": agent_id, "action": action, "target": target, "payload_hash": payload_hash, "verdict": verdict, "reason": reason, "prev_hash": prev_hash, "entry_hash": entry_hash, } # Атомарная запись строки в конец файла with open(self.filepath, "a", encoding="utf-8") as f: f.write(json.dumps(entry, ensure_ascii=False) + "\n") return entry def verify(self) -> Tuple[bool, int, str]: """ Выполняет полную математическую верификацию журнала по ГОСТ Р 34.11-2012. Возвращает: (is_valid, checked_count, message_or_error) """ if not os.path.exists(self.filepath) or os.path.getsize(self.filepath) == 0: return True, 0, "Журнал пуст." expected_prev_hash = GENESIS_PREV_HASH expected_index = 1 with open(self.filepath, "r", encoding="utf-8") as f: for line_no, line in enumerate(f, start=1): line_str = line.strip() if not line_str: continue try: entry = json.loads(line_str) except json.JSONDecodeError: return False, line_no, f"Ошибка синтаксиса JSON в строке {line_no}" # 1. Проверка непрерывности индексации if entry.get("index") != expected_index: return ( False, line_no, f"Нарушение нумерации записей в строке {line_no}: ожидалось {expected_index}, получено {entry.get('index')}", ) # 2. Проверка связи с предыдущим блоком if entry.get("prev_hash") != expected_prev_hash: return ( False, line_no, f"Нарушение криптографической цепочки в строке {line_no}: " f"prev_hash не совпадает с хэшем предыдущего блока!", ) # 3. Пересчет хэша записи по ГОСТ Р 34.11-2012 canonical_str = _canonical_string( index=entry["index"], timestamp=entry["timestamp"], agent_id=entry["agent_id"], action=entry["action"], target=entry["target"], payload_hash=entry["payload_hash"], verdict=entry["verdict"], reason=entry["reason"], prev_hash=entry["prev_hash"], ) calculated_hash = streebog256_hex(canonical_str.encode("utf-8")) if calculated_hash != entry.get("entry_hash"): return ( False, line_no, f"ФАЛЬСИФИКАЦИЯ ДАННЫХ в записи #{expected_index} (строка {line_no})! " f"Хэш ГОСТ Стрибог не сходится. Содержимое записи было изменено.", ) expected_prev_hash = calculated_hash expected_index += 1 total_checked = expected_index - 1 return True, total_checked, f"Целостность {total_checked} записей успешно подтверждена ГОСТ Р 34.11-2012." def get_stats(self) -> Dict[str, Any]: """Возвращает аналитическую сводку по журналу аудита.""" stats = { "total_records": 0, "permits": 0, "blocks": 0, "audits": 0, "agents": set(), "first_timestamp": None, "last_timestamp": None, "latest_hash": GENESIS_PREV_HASH, } if not os.path.exists(self.filepath): return stats with open(self.filepath, "r", encoding="utf-8") as f: for line in f: line_str = line.strip() if not line_str: continue try: entry = json.loads(line_str) except json.JSONDecodeError: continue stats["total_records"] += 1 verdict = entry.get("verdict", "") if verdict == "PERMIT": stats["permits"] += 1 elif verdict == "BLOCK": stats["blocks"] += 1 elif verdict == "AUDIT": stats["audits"] += 1 stats["agents"].add(entry.get("agent_id", "unknown")) if stats["first_timestamp"] is None: stats["first_timestamp"] = entry.get("timestamp") stats["last_timestamp"] = entry.get("timestamp") stats["latest_hash"] = entry.get("entry_hash", GENESIS_PREV_HASH) stats["agents"] = sorted(list(stats["agents"])) return stats