diff --git a/backend/alembic/versions/011_login_attempts.py b/backend/alembic/versions/011_login_attempts.py
new file mode 100644
index 0000000..7a52f9f
--- /dev/null
+++ b/backend/alembic/versions/011_login_attempts.py
@@ -0,0 +1,35 @@
+"""login_attempts for SAC UI login rate limiting
+
+Revision ID: 011
+Revises: 010
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "011"
+down_revision: Union[str, None] = "010"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "login_attempts",
+ sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column("ip_address", sa.String(length=64), nullable=False),
+ sa.Column("username", sa.String(length=64), nullable=True),
+ sa.Column("success", sa.Boolean(), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_login_attempts_ip_address", "login_attempts", ["ip_address"], unique=False)
+ op.create_index("ix_login_attempts_created_at", "login_attempts", ["created_at"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_login_attempts_created_at", table_name="login_attempts")
+ op.drop_index("ix_login_attempts_ip_address", table_name="login_attempts")
+ op.drop_table("login_attempts")
diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py
index 1364f5f..86d4870 100644
--- a/backend/app/api/v1/auth.py
+++ b/backend/app/api/v1/auth.py
@@ -1,64 +1,78 @@
-from fastapi import APIRouter, Depends, HTTPException
-from pydantic import BaseModel, Field
-from sqlalchemy.orm import Session
-
-from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
-from app.config import get_settings
-from app.database import get_db
-from app.services.user_auth import authenticate_user
-
-router = APIRouter(prefix="/auth", tags=["auth"])
-
-
-class LoginRequest(BaseModel):
- username: str
- password: str
-
-
-class TokenResponse(BaseModel):
- access_token: str
- token_type: str = "bearer"
- username: str
- role: str
-
-
-class MeResponse(BaseModel):
- username: str
- role: str
-
-
-@router.post("/login", response_model=TokenResponse)
-def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
- settings = get_settings()
- if not settings.sac_admin_password and not _has_any_user(db):
- raise HTTPException(
- status_code=503,
- detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
- )
- user = authenticate_user(db, body.username, body.password)
- if user is None:
- raise HTTPException(status_code=401, detail="Invalid username or password")
- token = create_access_token(user.username, user.role)
- return TokenResponse(
- access_token=token,
- username=user.username,
- role=user.role,
- )
-
-
-@router.get("/me", response_model=MeResponse)
-def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
- return MeResponse(username=current_user.username, role=current_user.role)
-
-
-def _has_any_user(db: Session) -> bool:
- from sqlalchemy import func, select
-
- from app.models.user import User
-
- try:
- count = db.scalar(select(func.count()).select_from(User)) or 0
- return count > 0
- except Exception:
- db.rollback()
- return False
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
+from app.config import get_settings
+from app.database import get_db
+from app.services.login_rate_limit import (
+ ensure_login_allowed,
+ record_login_failure,
+ record_login_success,
+)
+from app.services.user_auth import authenticate_user
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+class LoginRequest(BaseModel):
+ username: str
+ password: str
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+ username: str
+ role: str
+
+
+class MeResponse(BaseModel):
+ username: str
+ role: str
+
+
+@router.post("/login", response_model=TokenResponse)
+def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
+ settings = get_settings()
+ if not settings.sac_admin_password and not _has_any_user(db):
+ raise HTTPException(
+ status_code=503,
+ detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
+ )
+
+ ip_address = ensure_login_allowed(db, request)
+
+ user = authenticate_user(db, body.username, body.password)
+ if user is None:
+ record_login_failure(db, ip_address=ip_address, username=body.username)
+ db.commit()
+ raise HTTPException(status_code=401, detail="Invalid username or password")
+
+ record_login_success(db, ip_address=ip_address, username=user.username)
+ db.commit()
+
+ token = create_access_token(user.username, user.role)
+ return TokenResponse(
+ access_token=token,
+ username=user.username,
+ role=user.role,
+ )
+
+
+@router.get("/me", response_model=MeResponse)
+def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
+ return MeResponse(username=current_user.username, role=current_user.role)
+
+
+def _has_any_user(db: Session) -> bool:
+ from sqlalchemy import func, select
+
+ from app.models.user import User
+
+ try:
+ count = db.scalar(select(func.count()).select_from(User)) or 0
+ return count > 0
+ except Exception:
+ db.rollback()
+ return False
diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py
index 41c9dba..f0fe294 100644
--- a/backend/app/api/v1/hosts.py
+++ b/backend/app/api/v1/hosts.py
@@ -3,7 +3,7 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session
-from app.auth.jwt_auth import get_current_user
+from app.auth.jwt_auth import get_current_user, require_admin
from app.config import get_settings
from app.database import get_db
from app.models import Event, Host
@@ -91,7 +91,7 @@ class HostDeleteResponse(BaseModel):
def delete_host(
host_id: int,
db: Session = Depends(get_db),
- _user: str = Depends(get_current_user),
+ _user=Depends(require_admin),
) -> HostDeleteResponse:
result = delete_host_and_related(db, host_id)
if result is None:
diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py
index b2896f6..05c7709 100644
--- a/backend/app/api/v1/stream.py
+++ b/backend/app/api/v1/stream.py
@@ -8,7 +8,7 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.auth.jwt_auth import verify_access_token
-from app.database import SessionLocal
+from app.database import SessionLocal, get_db
from app.models import Event, Problem
from app.config import get_settings
from app.services.host_health import DAILY_REPORT_TYPES, HEARTBEAT_TYPE, count_stale_hosts
@@ -66,8 +66,11 @@ async def _sse_generator():
return
-def _sse_auth(token: str = Query(..., description="JWT access_token")) -> str:
- return verify_access_token(token)
+def _sse_auth(
+ token: str = Query(..., description="JWT access_token"),
+ db: Session = Depends(get_db),
+) -> str:
+ return verify_access_token(token, db=db)
@router.get("/events")
diff --git a/backend/app/auth/jwt_auth.py b/backend/app/auth/jwt_auth.py
index ab9c281..e9a4776 100644
--- a/backend/app/auth/jwt_auth.py
+++ b/backend/app/auth/jwt_auth.py
@@ -1,70 +1,171 @@
from dataclasses import dataclass
+
from datetime import datetime, timedelta, timezone
+
+
from fastapi import Depends, HTTPException, status
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
from jose import JWTError, jwt
+from sqlalchemy import func, select
+
+from sqlalchemy.orm import Session
+
+
+
from app.config import get_settings
-from app.models.user import USER_ROLE_ADMIN
+
+from app.database import get_db
+
+from app.models.user import USER_ROLE_ADMIN, User
+
+
bearer_scheme = HTTPBearer(auto_error=False)
+
+
+
@dataclass(frozen=True)
+
class CurrentUser:
+
username: str
+
role: str
+
+
@property
+
def is_admin(self) -> bool:
+
return self.role == USER_ROLE_ADMIN
+
+
+
def create_access_token(subject: str, role: str) -> str:
+
settings = get_settings()
+
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
+
payload = {"sub": subject, "role": role, "exp": expire}
+
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
-def _decode_current_user(token: str) -> CurrentUser:
+
+
+
+def _user_from_db(db: Session, username: str) -> User:
+
+ normalized = username.strip()
+
+ user = db.scalar(select(User).where(func.lower(User.username) == normalized.lower()))
+
+ if user is None or not user.is_active:
+
+ raise HTTPException(status_code=401, detail="Invalid token")
+
+ return user
+
+
+
+
+
+def _decode_current_user(token: str, db: Session | None = None) -> CurrentUser:
+
settings = get_settings()
+
try:
+
payload = jwt.decode(
+
token,
+
settings.jwt_secret,
+
algorithms=[settings.jwt_algorithm],
+
)
+
username = payload.get("sub")
+
if not username:
+
raise HTTPException(status_code=401, detail="Invalid token")
+
+ if db is not None:
+
+ user = _user_from_db(db, str(username))
+
+ return CurrentUser(username=user.username, role=user.role)
+
role = str(payload.get("role") or USER_ROLE_ADMIN)
+
return CurrentUser(username=str(username), role=role)
+
except JWTError as exc:
+
raise HTTPException(status_code=401, detail="Invalid token") from exc
-def verify_access_token(token: str) -> str:
+
+
+
+def verify_access_token(token: str, db: Session | None = None) -> str:
+
"""Проверка JWT (в т.ч. query-параметр для SSE). Возвращает username."""
- return _decode_current_user(token).username
+
+ return _decode_current_user(token, db=db).username
+
+
+
def get_current_user(
+
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+
+ db: Session = Depends(get_db),
+
) -> CurrentUser:
+
if credentials is None or credentials.scheme.lower() != "bearer":
+
raise HTTPException(
+
status_code=status.HTTP_401_UNAUTHORIZED,
+
detail="Not authenticated",
+
)
- return _decode_current_user(credentials.credentials)
+
+ return _decode_current_user(credentials.credentials, db=db)
+
+
+
def require_admin(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
+
if not user.is_admin:
+
raise HTTPException(
+
status_code=status.HTTP_403_FORBIDDEN,
+
detail="Admin access required",
+
)
+
return user
+
+
\ No newline at end of file
diff --git a/backend/app/config.py b/backend/app/config.py
index f94f629..90680c6 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -100,6 +100,11 @@ class Settings(BaseSettings):
sac_events_retention_days: int = 90
sac_problems_retention_days: int = 180
+ # UI login brute-force protection
+ sac_login_max_failures: int = 3
+ sac_login_failure_window_minutes: int = 15
+ sac_login_alert_telegram: bool = True
+
@lru_cache
def get_settings() -> Settings:
diff --git a/backend/app/main.py b/backend/app/main.py
index 2e5359a..4858002 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -105,9 +105,15 @@ def create_app() -> FastAPI:
def spa_fallback(spa_path: str) -> FileResponse:
if spa_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
- candidate = frontend_dist / spa_path
- if spa_path and candidate.is_file():
- return FileResponse(candidate)
+ dist_root = frontend_dist.resolve()
+ if spa_path:
+ candidate = (frontend_dist / spa_path).resolve()
+ try:
+ candidate.relative_to(dist_root)
+ except ValueError:
+ raise HTTPException(status_code=404, detail="Not Found") from None
+ if candidate.is_file():
+ return FileResponse(candidate)
index = frontend_dist / "index.html"
if not index.is_file():
raise HTTPException(status_code=404, detail="UI not built")
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 641d9b4..65f4b2e 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -5,6 +5,7 @@ from app.models.notification_channel import NotificationChannel
from app.models.notification_cooldown import NotificationCooldown
from app.models.notification_policy import NotificationPolicy
from app.models.problem import Problem, ProblemEvent
+from app.models.login_attempt import LoginAttempt
from app.models.event_severity_override import EventSeverityOverride
from app.models.user import User
@@ -19,4 +20,5 @@ __all__ = [
"ProblemEvent",
"User",
"EventSeverityOverride",
+ "LoginAttempt",
]
diff --git a/backend/app/models/login_attempt.py b/backend/app/models/login_attempt.py
new file mode 100644
index 0000000..56b0df6
--- /dev/null
+++ b/backend/app/models/login_attempt.py
@@ -0,0 +1,21 @@
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.database import Base
+
+
+class LoginAttempt(Base):
+ __tablename__ = "login_attempts"
+
+ id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
+ ip_address: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+ username: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ nullable=False,
+ index=True,
+ )
diff --git a/backend/app/services/login_rate_limit.py b/backend/app/services/login_rate_limit.py
new file mode 100644
index 0000000..f2fb9a8
--- /dev/null
+++ b/backend/app/services/login_rate_limit.py
@@ -0,0 +1,144 @@
+"""Rate limit failed SAC UI logins + optional Telegram alert."""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+
+from fastapi import HTTPException, Request
+from sqlalchemy import func, select
+from sqlalchemy.orm import Session
+
+from app.config import get_settings
+from app.models.login_attempt import LoginAttempt
+from app.services.telegram_notify import send_telegram_text
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True)
+class LoginRateLimitConfig:
+ max_failures: int
+ window_minutes: int
+ alert_telegram: bool
+
+
+def get_login_rate_limit_config() -> LoginRateLimitConfig:
+ settings = get_settings()
+ return LoginRateLimitConfig(
+ max_failures=max(1, int(getattr(settings, "sac_login_max_failures", 3) or 3)),
+ window_minutes=max(1, int(getattr(settings, "sac_login_failure_window_minutes", 15) or 15)),
+ alert_telegram=bool(getattr(settings, "sac_login_alert_telegram", True)),
+ )
+
+
+def client_ip_from_request(request: Request) -> str:
+ forwarded = (request.headers.get("x-forwarded-for") or "").strip()
+ if forwarded:
+ return forwarded.split(",")[0].strip()[:64]
+ if request.client and request.client.host:
+ return request.client.host[:64]
+ return "unknown"
+
+
+def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
+ return int(
+ db.scalar(
+ select(func.count())
+ .select_from(LoginAttempt)
+ .where(
+ LoginAttempt.ip_address == ip_address,
+ LoginAttempt.success.is_(False),
+ LoginAttempt.created_at >= since,
+ )
+ )
+ or 0
+ )
+
+
+def _alert_already_sent(db: Session, ip_address: str, *, since: datetime) -> bool:
+ """Avoid spamming Telegram on every subsequent 429."""
+ row = db.scalar(
+ select(LoginAttempt.username)
+ .where(
+ LoginAttempt.ip_address == ip_address,
+ LoginAttempt.success.is_(False),
+ LoginAttempt.created_at >= since,
+ LoginAttempt.username == "__alert_sent__",
+ )
+ .limit(1)
+ )
+ return row is not None
+
+
+def _send_login_bruteforce_alert(ip_address: str, username: str, failures: int) -> None:
+ user_hint = username if username else "—"
+ text = (
+ f"⚠️ SAC: подбор пароля UI\n"
+ f"IP: {ip_address}\n"
+ f"Логин: {user_hint}\n"
+ f"Неудачных попыток: {failures}\n"
+ f"Дальнейший вход с этого IP временно заблокирован."
+ )
+ try:
+ send_telegram_text(text, parse_mode="HTML")
+ except Exception:
+ logger.exception("failed to send SAC login brute-force Telegram alert")
+
+
+def ensure_login_allowed(db: Session, request: Request) -> str:
+ """Raise 429 if IP exceeded failed login threshold. Returns client IP."""
+ cfg = get_login_rate_limit_config()
+ ip_address = client_ip_from_request(request)
+ since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
+ failures = _failure_count(db, ip_address, since=since)
+ if failures >= cfg.max_failures:
+ raise HTTPException(
+ status_code=429,
+ detail=f"Слишком много неудачных попыток входа. Повторите через {cfg.window_minutes} мин.",
+ )
+ return ip_address
+
+
+def record_login_success(db: Session, *, ip_address: str, username: str) -> None:
+ db.add(
+ LoginAttempt(
+ ip_address=ip_address,
+ username=username[:64],
+ success=True,
+ )
+ )
+
+
+def record_login_failure(
+ db: Session,
+ *,
+ ip_address: str,
+ username: str,
+ request: Request | None = None,
+) -> None:
+ del request # reserved
+ cfg = get_login_rate_limit_config()
+ since = datetime.now(timezone.utc) - timedelta(minutes=cfg.window_minutes)
+ prior_failures = _failure_count(db, ip_address, since=since)
+
+ db.add(
+ LoginAttempt(
+ ip_address=ip_address,
+ username=username[:64] if username else None,
+ success=False,
+ )
+ )
+ db.flush()
+
+ new_failures = prior_failures + 1
+ if new_failures >= cfg.max_failures and cfg.alert_telegram and not _alert_already_sent(db, ip_address, since=since):
+ db.add(
+ LoginAttempt(
+ ip_address=ip_address,
+ username="__alert_sent__",
+ success=False,
+ )
+ )
+ _send_login_bruteforce_alert(ip_address, username, new_failures)
diff --git a/backend/app/version.py b/backend/app/version.py
index 5759b2c..9aafedc 100644
--- a/backend/app/version.py
+++ b/backend/app/version.py
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center"
-APP_VERSION = "0.7.1"
+APP_VERSION = "0.7.2"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py
index a4e941e..8567206 100644
--- a/backend/tests/test_health.py
+++ b/backend/tests/test_health.py
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
def test_version_constants():
- assert APP_VERSION == "0.7.1"
+ assert APP_VERSION == "0.7.2"
assert APP_NAME == "Security Alert Center"
- assert APP_VERSION_LABEL == "Security Alert Center v.0.7.1"
+ assert APP_VERSION_LABEL == "Security Alert Center v.0.7.2"
diff --git a/backend/tests/test_hosts.py b/backend/tests/test_hosts.py
index c27a961..34fffdc 100644
--- a/backend/tests/test_hosts.py
+++ b/backend/tests/test_hosts.py
@@ -77,6 +77,21 @@ def test_delete_host_404(client, jwt_headers):
assert r.status_code == 404
+def test_delete_host_forbidden_for_monitor(client, db_session, jwt_monitor_headers):
+ h = Host(
+ hostname="monitor-blocked",
+ os_family="linux",
+ product="ssh-monitor",
+ last_seen_at=datetime.now(timezone.utc),
+ )
+ db_session.add(h)
+ db_session.commit()
+
+ r = client.delete(f"/api/v1/hosts/{h.id}", headers=jwt_monitor_headers)
+ assert r.status_code == 403
+ assert db_session.get(Host, h.id) is not None
+
+
def test_delete_host_requires_jwt(client, db_session):
h = Host(
hostname="no-auth",
diff --git a/backend/tests/test_login_rate_limit.py b/backend/tests/test_login_rate_limit.py
new file mode 100644
index 0000000..fc0b5a2
--- /dev/null
+++ b/backend/tests/test_login_rate_limit.py
@@ -0,0 +1,65 @@
+"""SAC UI login rate limit and Telegram alert on brute-force."""
+
+
+def _failed_login(client, username: str = "test-admin") -> int:
+ return client.post(
+ "/api/v1/auth/login",
+ json={"username": username, "password": "wrong-password"},
+ ).status_code
+
+
+def test_login_blocked_after_max_failures(client, monkeypatch):
+ monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3")
+ from app.config import get_settings
+
+ get_settings.cache_clear()
+
+ assert _failed_login(client) == 401
+ assert _failed_login(client) == 401
+ assert _failed_login(client) == 401
+ assert _failed_login(client) == 429
+
+ ok = client.post(
+ "/api/v1/auth/login",
+ json={"username": "test-admin", "password": "test-admin-password"},
+ )
+ assert ok.status_code == 429
+
+ get_settings.cache_clear()
+
+
+def test_login_telegram_alert_on_threshold(client, monkeypatch):
+ monkeypatch.setenv("SAC_LOGIN_MAX_FAILURES", "3")
+ monkeypatch.setenv("SAC_LOGIN_ALERT_TELEGRAM", "true")
+ from app.config import get_settings
+
+ get_settings.cache_clear()
+
+ sent: list[str] = []
+ monkeypatch.setattr(
+ "app.services.login_rate_limit.send_telegram_text",
+ lambda text, **kwargs: sent.append(text),
+ )
+
+ for _ in range(3):
+ assert _failed_login(client, username="attacker") == 401
+
+ assert len(sent) == 1
+ assert "SAC: подбор пароля UI" in sent[0]
+ assert "attacker" in sent[0]
+
+ assert _failed_login(client, username="attacker") == 429
+ assert len(sent) == 1
+
+ get_settings.cache_clear()
+
+
+def test_deactivated_user_token_rejected_on_me(client, jwt_monitor_headers, db_session):
+ from app.models.user import User
+
+ user = db_session.query(User).filter(User.username == "test-monitor").one()
+ user.is_active = False
+ db_session.commit()
+
+ response = client.get("/api/v1/auth/me", headers=jwt_monitor_headers)
+ assert response.status_code == 401
diff --git a/deploy/env.native.example b/deploy/env.native.example
index 609fa94..a34cbfc 100644
--- a/deploy/env.native.example
+++ b/deploy/env.native.example
@@ -78,4 +78,9 @@ SAC_PRIVILEGE_SPIKE_THRESHOLD=10
SAC_EVENTS_RETENTION_DAYS=90
SAC_PROBLEMS_RETENTION_DAYS=180
+# UI login: блокировка IP после N неудачных попыток (окно в минутах) + алерт в Telegram
+SAC_LOGIN_MAX_FAILURES=3
+SAC_LOGIN_FAILURE_WINDOW_MINUTES=15
+SAC_LOGIN_ALERT_TELEGRAM=true
+
CORS_ORIGINS=*
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 3371d55..b549af2 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,17 +1,19 @@
{
"name": "sac-ui",
- "version": "0.1.0",
+ "version": "0.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sac-ui",
- "version": "0.1.0",
+ "version": "0.7.2",
"dependencies": {
+ "dompurify": "^3.2.4",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
+ "@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.7.2",
"vite": "^6.0.3",
@@ -901,6 +903,17 @@
"win32"
]
},
+ "node_modules/@types/dompurify": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz",
+ "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
+ "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "dompurify": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -908,6 +921,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1130,6 +1150,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/dompurify": {
+ "version": "3.4.7",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz",
+ "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5cf835a..8c40411 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "sac-ui",
"private": true,
- "version": "0.7.1",
+ "version": "0.7.2",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "dompurify": "^3.2.4",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
diff --git a/frontend/src/utils/reportDisplay.ts b/frontend/src/utils/reportDisplay.ts
index 277ea57..8eba0d4 100644
--- a/frontend/src/utils/reportDisplay.ts
+++ b/frontend/src/utils/reportDisplay.ts
@@ -1,20 +1,30 @@
-/** Безопасный вывод HTML из агента (только базовая разметка). */
+/** Безопасный вывод HTML из агента (DOMPurify, белый список тегов). */
+import DOMPurify from "dompurify";
+
+const ALLOWED_REPORT_TAGS = [
+ "b",
+ "strong",
+ "i",
+ "em",
+ "u",
+ "code",
+ "pre",
+ "br",
+ "p",
+ "div",
+ "span",
+ "ul",
+ "ol",
+ "li",
+ "hr",
+];
+
export function sanitizeAgentHtml(html: string): string {
- const allowed = /^(b|strong|i|em|u|code|pre|br|p|div|span|ul|ol|li|hr)$/i;
- return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (tag, name: string) => {
- if (allowed.test(name)) {
- if (tag.toLowerCase().startsWith("")) {
- return `${name.toLowerCase()}>`;
- }
- if (name.toLowerCase() === "br") {
- return "
";
- }
- return `<${name.toLowerCase()}>`;
- }
- return "";
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS: ALLOWED_REPORT_TAGS,
+ ALLOWED_ATTR: [],
});
}
-
export interface DailyReportStats {
platform?: "ssh" | "windows";
successful_logins?: number;
diff --git a/frontend/src/version.ts b/frontend/src/version.ts
index ce77a57..e07f152 100644
--- a/frontend/src/version.ts
+++ b/frontend/src/version.ts
@@ -1,3 +1,3 @@
export const APP_NAME = "Security Alert Center";
-export const APP_VERSION = "0.7.1";
+export const APP_VERSION = "0.7.2";
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;