mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
129 lines
4.1 KiB
Python
129 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import uuid
|
|
from datetime import timedelta
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, inspect
|
|
from sqlalchemy.exc import SQLAlchemyError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models.security_audit_log import SecurityAuditLog
|
|
from app.models.common import utcnow
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SUSPICIOUS_DENY_WINDOW_MINUTES = 10
|
|
SUSPICIOUS_DENY_THRESHOLD = 5
|
|
|
|
|
|
def _uuid_or_none(raw: str | uuid.UUID | None) -> uuid.UUID | None:
|
|
if raw is None:
|
|
return None
|
|
if isinstance(raw, uuid.UUID):
|
|
return raw
|
|
try:
|
|
return uuid.UUID(str(raw))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]:
|
|
if not isinstance(details, dict):
|
|
return {}
|
|
safe: dict[str, Any] = {}
|
|
for key, value in details.items():
|
|
if value is None or isinstance(value, (str, int, float, bool)):
|
|
safe[str(key)] = value
|
|
else:
|
|
safe[str(key)] = str(value)
|
|
return safe
|
|
|
|
|
|
def _emit_suspicious_denied_download_alert(
|
|
db: Session,
|
|
*,
|
|
actor_role: str,
|
|
actor_subject: str,
|
|
actor_ip: str | None,
|
|
) -> None:
|
|
if not actor_subject and not actor_ip:
|
|
return
|
|
since = utcnow() - timedelta(minutes=SUSPICIOUS_DENY_WINDOW_MINUTES)
|
|
query = db.query(func.count(SecurityAuditLog.id)).filter(
|
|
SecurityAuditLog.created_at >= since,
|
|
SecurityAuditLog.action == "DOWNLOAD_OBJECT",
|
|
SecurityAuditLog.allowed.is_(False),
|
|
)
|
|
if actor_subject:
|
|
query = query.filter(SecurityAuditLog.actor_subject == actor_subject)
|
|
elif actor_ip:
|
|
query = query.filter(SecurityAuditLog.actor_ip == actor_ip)
|
|
denied_count = int(query.scalar() or 0)
|
|
if denied_count >= SUSPICIOUS_DENY_THRESHOLD:
|
|
logger.warning(
|
|
"SECURITY_ALERT repeated denied download attempts role=%s subject=%s ip=%s count=%s window_min=%s",
|
|
actor_role,
|
|
actor_subject or "-",
|
|
actor_ip or "-",
|
|
denied_count,
|
|
SUSPICIOUS_DENY_WINDOW_MINUTES,
|
|
)
|
|
|
|
|
|
def record_file_security_event(
|
|
db: Session,
|
|
*,
|
|
actor_role: str,
|
|
actor_subject: str,
|
|
actor_ip: str | None,
|
|
action: str,
|
|
scope: str,
|
|
allowed: bool,
|
|
reason: str | None = None,
|
|
object_key: str | None = None,
|
|
request_id: str | uuid.UUID | None = None,
|
|
attachment_id: str | uuid.UUID | None = None,
|
|
details: dict[str, Any] | None = None,
|
|
responsible: str | None = None,
|
|
persist_now: bool = False,
|
|
) -> None:
|
|
# Security telemetry must not block business flow if DB log write fails.
|
|
try:
|
|
bind = db.get_bind()
|
|
if bind is None or not inspect(bind).has_table("security_audit_log"):
|
|
return
|
|
row = SecurityAuditLog(
|
|
actor_role=str(actor_role or "UNKNOWN").upper(),
|
|
actor_subject=str(actor_subject or "").strip(),
|
|
actor_ip=str(actor_ip or "").strip() or None,
|
|
action=str(action or "").strip().upper() or "UNKNOWN",
|
|
scope=str(scope or "").strip().upper() or "UNKNOWN",
|
|
object_key=str(object_key or "").strip() or None,
|
|
request_id=_uuid_or_none(request_id),
|
|
attachment_id=_uuid_or_none(attachment_id),
|
|
allowed=bool(allowed),
|
|
reason=(str(reason)[:400] if reason is not None else None),
|
|
details=_safe_details(details),
|
|
responsible=str(responsible or "Администратор системы").strip() or "Администратор системы",
|
|
)
|
|
db.add(row)
|
|
db.flush()
|
|
|
|
if not bool(allowed) and str(action or "").upper() == "DOWNLOAD_OBJECT":
|
|
_emit_suspicious_denied_download_alert(
|
|
db,
|
|
actor_role=row.actor_role,
|
|
actor_subject=row.actor_subject,
|
|
actor_ip=row.actor_ip,
|
|
)
|
|
|
|
if persist_now:
|
|
db.commit()
|
|
except SQLAlchemyError:
|
|
if persist_now:
|
|
try:
|
|
db.rollback()
|
|
except Exception:
|
|
logger.debug("security_audit_rollback_failed", exc_info=True)
|