From 80320ae6989deb103b0314b062703be81663fd0d Mon Sep 17 00:00:00 2001 From: PTah Date: Tue, 7 Jul 2026 19:32:12 +1000 Subject: [PATCH] fix: harden SAC security per audit (0.4.15) Close audit findings: anti-spoof client IP for login rate limit, JWT/CORS enforce on startup, SSH host-key verification and sudo via stdin, optional WinRM HTTPS, SSE httpOnly cookie auth, ingest body limit, ILIKE escaping, and HMAC API key hashing with legacy SHA-256 fallback. --- backend/app/api/v1/auth.py | 98 +++---------------- backend/app/api/v1/events.py | 13 +-- backend/app/api/v1/hosts.py | 5 +- backend/app/api/v1/problems.py | 7 +- backend/app/api/v1/stream.py | 10 +- backend/app/auth/api_key.py | 25 ++++- backend/app/auth/stream_cookie.py | 37 +++++++ backend/app/config.py | 9 +- backend/app/main.py | 17 +++- backend/app/middleware/ingest_body_limit.py | 28 ++++++ backend/app/security_bootstrap.py | 52 ++++++++++ backend/app/services/client_ip.py | 27 +++++ backend/app/services/login_rate_limit.py | 12 +-- backend/app/services/ssh_connect.py | 68 ++++++++++--- backend/app/services/winrm_connect.py | 13 ++- backend/app/utils/sql_like.py | 8 ++ backend/app/version.py | 4 +- backend/tests/conftest.py | 7 +- backend/tests/test_client_ip.py | 26 +++++ backend/tests/test_health.py | 6 +- backend/tests/test_ingest_body_limit.py | 23 +++++ backend/tests/test_security_hardening.py | 52 ++++++++++ backend/tests/test_ssh_connect.py | 18 ++-- deploy/env.native.example | 13 ++- frontend/src/api.ts | 12 ++- frontend/src/components/AppSidebar.vue | 10 +- .../src/composables/useSacLiveEventStream.ts | 4 +- frontend/src/version.ts | 4 +- frontend/src/views/DashboardView.vue | 4 +- 29 files changed, 452 insertions(+), 160 deletions(-) create mode 100644 backend/app/auth/stream_cookie.py create mode 100644 backend/app/middleware/ingest_body_limit.py create mode 100644 backend/app/security_bootstrap.py create mode 100644 backend/app/services/client_ip.py create mode 100644 backend/app/utils/sql_like.py create mode 100644 backend/tests/test_client_ip.py create mode 100644 backend/tests/test_ingest_body_limit.py create mode 100644 backend/tests/test_security_hardening.py diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 2e11abd..0f2cb36 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,156 +1,90 @@ -from fastapi import APIRouter, Depends, HTTPException, Request - -from pydantic import BaseModel, Field - +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel +from sqlalchemy import func, select from sqlalchemy.orm import Session - - from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user - +from app.auth.stream_cookie import clear_stream_auth_cookie, set_stream_auth_cookie 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: - +def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse: 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( - + payload = TokenResponse( access_token=token, - username=user.username, - role=user.role, - ) + response = JSONResponse(content=payload.model_dump()) + set_stream_auth_cookie(response, token, settings) + return response - +@router.post("/logout", status_code=204) +def logout() -> Response: + settings = get_settings() + response = Response(status_code=204) + clear_stream_auth_cookie(response, settings) + return response @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/events.py b/backend/app/api/v1/events.py index b0b4734..7afef5c 100644 --- a/backend/app/api/v1/events.py +++ b/backend/app/api/v1/events.py @@ -14,6 +14,7 @@ from app.config import get_settings from app.database import get_db from app.models import Event, Host from app.schemas.list_models import EventDetail, EventListResponse, EventSummary +from app.utils.sql_like import escape_ilike_pattern from app.services.agent_commands import ( command_response_fields, command_to_dict, @@ -185,9 +186,9 @@ def list_events( stmt = stmt.where(Event.host_id == host_id) count_stmt = count_stmt.where(Event.host_id == host_id) if hostname: - like = f"%{hostname}%" - stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) - count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + like = f"%{escape_ilike_pattern(hostname)}%" + stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) + count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) dt_from = _parse_optional_dt(from_time) dt_to = _parse_optional_dt(to_time, end_of_day=True) if dt_from: @@ -197,9 +198,9 @@ def list_events( stmt = stmt.where(Event.occurred_at <= dt_to) count_stmt = count_stmt.where(Event.occurred_at <= dt_to) if q: - like = f"%{q}%" - stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like)) - count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like)) + like = f"%{escape_ilike_pattern(q)}%" + stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\")) + count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\")) total = db.scalar(count_stmt) or 0 rows = db.scalars( diff --git a/backend/app/api/v1/hosts.py b/backend/app/api/v1/hosts.py index a809225..4fecc27 100644 --- a/backend/app/api/v1/hosts.py +++ b/backend/app/api/v1/hosts.py @@ -12,6 +12,7 @@ from app.config import get_settings from app.database import get_db from app.models import Event, Host from app.schemas.list_models import HostDetail, HostListResponse, HostSummary +from app.utils.sql_like import escape_ilike_pattern from app.services.agent_version import ( latest_agent_versions_by_product, reference_agent_versions_by_product, @@ -101,8 +102,8 @@ def list_hosts( stmt = stmt.where(Host.product == product) count_stmt = count_stmt.where(Host.product == product) if hostname: - like = f"%{hostname}%" - host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like) + like = f"%{escape_ilike_pattern(hostname)}%" + host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\") stmt = stmt.where(host_match) count_stmt = count_stmt.where(host_match) diff --git a/backend/app/api/v1/problems.py b/backend/app/api/v1/problems.py index 183be8e..56bc74b 100644 --- a/backend/app/api/v1/problems.py +++ b/backend/app/api/v1/problems.py @@ -18,6 +18,7 @@ from app.database import get_db from app.models import Event, Host, Problem, ProblemEvent from app.services.event_type_visibility import get_hidden_event_types +from app.utils.sql_like import escape_ilike_pattern @@ -193,11 +194,11 @@ def list_problems( if hostname: - like = f"%{hostname}%" + like = f"%{escape_ilike_pattern(hostname)}%" - stmt = stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + stmt = stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) - count_stmt = count_stmt.where(Host.hostname.ilike(like) | Host.display_name.ilike(like)) + count_stmt = count_stmt.where(Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")) if created_within_hours is not None: diff --git a/backend/app/api/v1/stream.py b/backend/app/api/v1/stream.py index 05c7709..17584c7 100644 --- a/backend/app/api/v1/stream.py +++ b/backend/app/api/v1/stream.py @@ -1,13 +1,14 @@ -import asyncio +import asyncio import json from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Cookie, Depends, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy import func, select from sqlalchemy.orm import Session from app.auth.jwt_auth import verify_access_token +from app.auth.stream_cookie import STREAM_COOKIE_NAME from app.database import SessionLocal, get_db from app.models import Event, Problem from app.config import get_settings @@ -67,9 +68,12 @@ async def _sse_generator(): def _sse_auth( - token: str = Query(..., description="JWT access_token"), + sac_stream: str | None = Cookie(None, alias=STREAM_COOKIE_NAME), db: Session = Depends(get_db), ) -> str: + token = (sac_stream or "").strip() + if not token: + raise HTTPException(status_code=401, detail="SSE authentication required") return verify_access_token(token, db=db) diff --git a/backend/app/auth/api_key.py b/backend/app/auth/api_key.py index 138dd2d..ead5118 100644 --- a/backend/app/auth/api_key.py +++ b/backend/app/auth/api_key.py @@ -1,4 +1,5 @@ -import hashlib +import hashlib +import hmac import secrets from fastapi import Depends, HTTPException, Security @@ -6,16 +7,28 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.orm import Session +from app.config import get_settings from app.database import get_db from app.models import ApiKey security_scheme = HTTPBearer(auto_error=False) -def hash_api_key(raw_key: str) -> str: +def _legacy_hash_api_key(raw_key: str) -> str: return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() +def hash_api_key(raw_key: str) -> str: + secret = get_settings().jwt_secret.encode("utf-8") + return hmac.new(secret, raw_key.encode("utf-8"), hashlib.sha256).hexdigest() + + +def verify_api_key_hash(raw_key: str, stored_hash: str) -> bool: + if hmac.compare_digest(hash_api_key(raw_key), stored_hash): + return True + return hmac.compare_digest(_legacy_hash_api_key(raw_key), stored_hash) + + def generate_api_key() -> tuple[str, str, str]: """Returns (full_key, prefix, hash).""" raw = f"sac_{secrets.token_urlsafe(32)}" @@ -24,9 +37,11 @@ def generate_api_key() -> tuple[str, str, str]: def verify_api_key(db: Session, raw_key: str) -> bool: - key_hash = hash_api_key(raw_key) - row = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active.is_(True))) - return row is not None + rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all() + for row in rows: + if verify_api_key_hash(raw_key, row.key_hash): + return True + return False def get_api_key_auth( diff --git a/backend/app/auth/stream_cookie.py b/backend/app/auth/stream_cookie.py new file mode 100644 index 0000000..52a6fca --- /dev/null +++ b/backend/app/auth/stream_cookie.py @@ -0,0 +1,37 @@ +"""httpOnly cookie auth for SSE (EventSource cannot send Authorization header).""" + +from __future__ import annotations + +from fastapi import Response + +from app.config import Settings + +STREAM_COOKIE_NAME = "sac_stream" +STREAM_COOKIE_PATH = "/api/v1/stream" + + +def stream_cookie_kwargs(settings: Settings) -> dict[str, object]: + secure = settings.sac_public_url.lower().startswith("https://") + return { + "key": STREAM_COOKIE_NAME, + "httponly": True, + "secure": secure, + "samesite": "strict", + "path": STREAM_COOKIE_PATH, + "max_age": max(60, int(settings.jwt_expire_minutes) * 60), + } + + +def set_stream_auth_cookie(response: Response, token: str, settings: Settings) -> None: + response.set_cookie(value=token, **stream_cookie_kwargs(settings)) + + +def clear_stream_auth_cookie(response: Response, settings: Settings) -> None: + kwargs = stream_cookie_kwargs(settings) + response.delete_cookie( + key=kwargs["key"], + path=kwargs["path"], + secure=bool(kwargs["secure"]), + httponly=True, + samesite="strict", + ) diff --git a/backend/app/config.py b/backend/app/config.py index 9cdcdd4..3f657ef 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,4 +1,4 @@ -import os +import os from functools import lru_cache from pathlib import Path @@ -42,6 +42,9 @@ class Settings(BaseSettings): jwt_secret: str = "change-me-in-production" jwt_algorithm: str = "HS256" jwt_expire_minutes: int = 60 * 24 + # Fail-fast on weak JWT/CORS defaults (disable only for local dev/tests). + sac_security_enforce: bool = True + sac_ingest_max_body_bytes: int = 2_097_152 sac_bootstrap_api_key: str = "" sac_admin_username: str = "admin" @@ -116,10 +119,14 @@ class Settings(BaseSettings): # Windows admin for agent qwinsta/logoff (domain-wide) sac_win_admin_user: str = "" sac_win_admin_password: str = "" + sac_winrm_use_https: bool = False + sac_winrm_server_cert_validation: str = "validate" # Linux SSH admin for remote agent update (fallback SSH, phase 5) sac_linux_admin_user: str = "" sac_linux_admin_password: str = "" + sac_ssh_known_hosts_file: str = "/opt/security-alert-center/config/ssh_known_hosts" + sac_ssh_auto_add_host_key: bool = False # Agent updates (mode gpo|sac, recommended versions, WinRM fallback script) sac_agent_update_mode: str = "gpo" diff --git a/backend/app/main.py b/backend/app/main.py index c544e6d..7329338 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,4 @@ -import asyncio +import asyncio import logging from contextlib import asynccontextmanager, suppress from pathlib import Path @@ -13,7 +13,9 @@ from app.api.v1.router import api_router from app.auth.api_key import hash_api_key from app.config import get_settings from app.database import SessionLocal +from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware from app.models import ApiKey +from app.security_bootstrap import validate_security_settings, warn_relaxed_security from app.services.user_auth import bootstrap_admin_user from app.version import APP_VERSION, APP_VERSION_LABEL @@ -67,6 +69,9 @@ def bootstrap_stale_remote_actions() -> None: @asynccontextmanager async def lifespan(_app: FastAPI): + settings = get_settings() + validate_security_settings(settings) + warn_relaxed_security(settings) logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION) bootstrap_api_key() bootstrap_users() @@ -74,7 +79,6 @@ async def lifespan(_app: FastAPI): stop_scan = asyncio.Event() scan_task: asyncio.Task | None = None - settings = get_settings() if settings.sac_host_silence_scan_enabled: from app.jobs.host_silence_background import host_silence_scan_loop @@ -108,13 +112,18 @@ def create_app() -> FastAPI: lifespan=lifespan, ) origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()] + wildcard = origins == ["*"] app.add_middleware( CORSMiddleware, - allow_origins=origins if origins != ["*"] else ["*"], - allow_credentials=True, + allow_origins=origins if not wildcard else ["*"], + allow_credentials=not wildcard, allow_methods=["*"], allow_headers=["*"], ) + app.add_middleware( + IngestBodySizeLimitMiddleware, + max_bytes=settings.sac_ingest_max_body_bytes, + ) from app.api.v1.health import router as health_router app.include_router(api_router, prefix="/api/v1") diff --git a/backend/app/middleware/ingest_body_limit.py b/backend/app/middleware/ingest_body_limit.py new file mode 100644 index 0000000..37e0c80 --- /dev/null +++ b/backend/app/middleware/ingest_body_limit.py @@ -0,0 +1,28 @@ +"""Limit POST /api/v1/events body size (matches nginx client_max_body_size).""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + + +class IngestBodySizeLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app, *, max_bytes: int = 2_097_152) -> None: + super().__init__(app) + self._max_bytes = max_bytes + + async def dispatch(self, request: Request, call_next): + if request.method == "POST" and request.url.path.rstrip("/") == "/api/v1/events": + content_length = request.headers.get("content-length") + if content_length: + try: + size = int(content_length) + except ValueError: + size = 0 + if size > self._max_bytes: + return JSONResponse( + status_code=413, + content={"detail": f"Request body too large (max {self._max_bytes} bytes)"}, + ) + return await call_next(request) diff --git a/backend/app/security_bootstrap.py b/backend/app/security_bootstrap.py new file mode 100644 index 0000000..ff1e386 --- /dev/null +++ b/backend/app/security_bootstrap.py @@ -0,0 +1,52 @@ +"""Fail-fast checks for insecure production defaults.""" + +from __future__ import annotations + +import logging +from urllib.parse import urlparse + +from app.config import Settings + +logger = logging.getLogger("sac") + +_WEAK_JWT_SECRETS = frozenset( + { + "", + "change-me-in-production", + "change-me-openssl-rand-hex-32", + "CHANGE_ME_openssl_rand_hex_32", + } +) + + +def _is_local_dev_url(public_url: str) -> bool: + parsed = urlparse(public_url.strip() or "http://localhost:8000") + host = (parsed.hostname or "").lower() + return host in {"localhost", "127.0.0.1", "::1", "testserver"} + + +def validate_security_settings(settings: Settings) -> None: + """Raise on dangerous defaults when SAC_SECURITY_ENFORCE=true.""" + if not settings.sac_security_enforce: + return + + if settings.jwt_secret in _WEAK_JWT_SECRETS: + raise RuntimeError( + "JWT_SECRET is missing or uses an insecure default. " + "Set a random value in sac-api.env (openssl rand -hex 32)." + ) + + if settings.cors_origins.strip() == "*" and not _is_local_dev_url(settings.sac_public_url): + raise RuntimeError( + "CORS_ORIGINS=* is not allowed with SAC_SECURITY_ENFORCE=true on non-local SAC_PUBLIC_URL. " + "Set CORS_ORIGINS to your UI origin, e.g. https://sac.example.com" + ) + + +def warn_relaxed_security(settings: Settings) -> None: + if settings.sac_security_enforce: + return + if settings.jwt_secret in _WEAK_JWT_SECRETS: + logger.warning("JWT_SECRET uses insecure default — set a strong secret before production") + if settings.cors_origins.strip() == "*": + logger.warning("CORS_ORIGINS=* — restrict to explicit origins in production") diff --git a/backend/app/services/client_ip.py b/backend/app/services/client_ip.py new file mode 100644 index 0000000..fc3de6e --- /dev/null +++ b/backend/app/services/client_ip.py @@ -0,0 +1,27 @@ +"""Resolve client IP behind reverse proxy (nginx $proxy_add_x_forwarded_for).""" + +from __future__ import annotations + +from fastapi import Request + + +def client_ip_from_request(request: Request) -> str: + """Client IP for rate limiting. + + nginx with ``proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`` + appends the real peer address as the *last* hop. Spoofed values in the + incoming header therefore cannot replace the actual client IP. + """ + if request.client and request.client.host: + direct = request.client.host.strip() + else: + direct = "unknown" + + forwarded = (request.headers.get("x-forwarded-for") or "").strip() + if not forwarded: + return direct[:64] + + parts = [part.strip() for part in forwarded.split(",") if part.strip()] + if not parts: + return direct[:64] + return parts[-1][:64] diff --git a/backend/app/services/login_rate_limit.py b/backend/app/services/login_rate_limit.py index ffb95e6..b62ef96 100644 --- a/backend/app/services/login_rate_limit.py +++ b/backend/app/services/login_rate_limit.py @@ -1,4 +1,4 @@ -"""Rate limit failed SAC UI logins + optional Telegram alert.""" +"""Rate limit failed SAC UI logins + optional Telegram alert.""" from __future__ import annotations @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from app.config import get_settings from app.models.login_attempt import LoginAttempt +from app.services.client_ip import client_ip_from_request from app.services.login_security_settings import ( get_effective_login_security_config, is_ip_login_whitelisted, @@ -37,15 +38,6 @@ def get_login_rate_limit_config() -> LoginRateLimitConfig: ) -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( diff --git a/backend/app/services/ssh_connect.py b/backend/app/services/ssh_connect.py index c5416b5..6542ab9 100644 --- a/backend/app/services/ssh_connect.py +++ b/backend/app/services/ssh_connect.py @@ -1,4 +1,4 @@ -"""SSH connectivity and remote commands for Linux hosts.""" +"""SSH connectivity and remote commands for Linux hosts.""" from __future__ import annotations @@ -6,6 +6,7 @@ import re import socket from dataclasses import dataclass +from app.config import get_settings from app.models import Host SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh" @@ -187,20 +188,55 @@ def _shell_single_quote(value: str) -> str: def _remote_shell_command( user: str, remote_cmd: str, - password: str, *, need_root: bool = False, login_shell: bool = True, -) -> str: - """Build remote shell command. Sudo only when need_root and user is not root.""" +) -> tuple[str, bool]: + """Build remote shell command. Returns (command, needs_sudo_password_on_stdin).""" safe_cmd = _shell_single_quote(remote_cmd) bash_flag = "lc" if login_shell else "c" if user == "root" or not need_root: - return f"bash -{bash_flag} {safe_cmd}" + return f"bash -{bash_flag} {safe_cmd}", False + return f"sudo -S -p '' bash -{bash_flag} {safe_cmd}", True - safe_pw = _shell_single_quote(password) - inner = f"printf '%s\\n' {safe_pw} | sudo -S -p '' bash -{bash_flag} {safe_cmd}" - return f"bash -{bash_flag} {_shell_single_quote(inner)}" + +def _configure_ssh_client(client, target: str) -> None: + import paramiko + + settings = get_settings() + if settings.sac_ssh_auto_add_host_key: + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + return + + client.set_missing_host_key_policy(paramiko.RejectPolicy()) + client.load_system_host_keys() + known_hosts = (settings.sac_ssh_known_hosts_file or "").strip() + if known_hosts: + try: + client.load_host_keys(known_hosts) + except OSError: + pass + + +def _exec_remote_command( + client, + *, + shell_cmd: str, + password: str, + needs_sudo_password: bool, + command_timeout_sec: int, +) -> tuple[int, str, str]: + if needs_sudo_password: + stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec, get_pty=True) + stdin.write(f"{password}\n") + stdin.flush() + stdin.channel.shutdown_write() + else: + _stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec) + exit_code = stdout.channel.recv_exit_status() + out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace")) + err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace")) + return exit_code, out_text, err_text def run_ssh_command( @@ -224,10 +260,9 @@ def run_ssh_command( except ImportError as exc: raise RuntimeError("paramiko is not installed on SAC server") from exc - shell_cmd = _remote_shell_command( + shell_cmd, needs_sudo_password = _remote_shell_command( user, remote_cmd, - password, need_root=need_root, login_shell=login_shell, ) @@ -237,8 +272,8 @@ def run_ssh_command( for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1): client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: + _configure_ssh_client(client, target) _connect_ssh_client( client, target=target, @@ -246,10 +281,13 @@ def run_ssh_command( password=password, connect_timeout_sec=connect_timeout_sec, ) - _stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec) - exit_code = stdout.channel.recv_exit_status() - out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace")) - err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace")) + exit_code, out_text, err_text = _exec_remote_command( + client, + shell_cmd=shell_cmd, + password=password, + needs_sudo_password=needs_sudo_password, + command_timeout_sec=command_timeout_sec, + ) break except paramiko.AuthenticationException: return SshCommandResult( diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index 4205f29..0fe26a1 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -1,4 +1,4 @@ -"""WinRM connectivity test for Windows hosts using domain admin credentials.""" +"""WinRM connectivity test for Windows hosts using domain admin credentials.""" from __future__ import annotations @@ -92,13 +92,20 @@ def _winrm_session( operation_timeout_sec = max(5, timeout_sec) read_timeout_sec = operation_timeout_sec + 15 - endpoint = f"http://{target}:5985/wsman" + settings = get_settings() + scheme = "https" if settings.sac_winrm_use_https else "http" + port = 5986 if settings.sac_winrm_use_https else 5985 + endpoint = f"{scheme}://{target}:{port}/wsman" + cert_validation = (settings.sac_winrm_server_cert_validation or "validate").strip().lower() + if cert_validation not in {"validate", "ignore"}: + cert_validation = "validate" session = winrm.Session( endpoint, auth=(user, password), transport="ntlm", read_timeout_sec=read_timeout_sec, operation_timeout_sec=operation_timeout_sec, + server_cert_validation=cert_validation, ) return session, winrm @@ -540,7 +547,7 @@ def run_winrm_rdp_monitor_update( ok=False, message=( f"Failed to download RDP bundle on client: {download.message} " - f"(URL: {bundle_url})" + "(bundle URL omitted from logs)" ), target=target, stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()), diff --git a/backend/app/utils/sql_like.py b/backend/app/utils/sql_like.py new file mode 100644 index 0000000..6e22dbd --- /dev/null +++ b/backend/app/utils/sql_like.py @@ -0,0 +1,8 @@ +"""Escape user input for SQL ILIKE patterns.""" + + +def escape_ilike_pattern(value: str) -> str: + text = (value or "").strip() + if not text: + return text + return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/backend/app/version.py b/backend/app/version.py index a3aaee0..4a61071 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ -"""Единый источник версии SAC (API, health, логи, OpenAPI).""" +"""Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.4.14" +APP_VERSION = "0.4.15" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d71005f..9387650 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,10 +1,14 @@ -"""SQLite in-memory fixtures for API tests.""" +"""SQLite in-memory fixtures for API tests.""" import os # Must be set before app.database imports create_engine os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only") +os.environ.setdefault("SAC_SECURITY_ENFORCE", "false") +os.environ.setdefault("JWT_SECRET", "pytest-jwt-secret-not-for-production-use") +os.environ.setdefault("SAC_SSH_AUTO_ADD_HOST_KEY", "true") +os.environ.setdefault("SAC_HOST_SILENCE_SCAN_ENABLED", "false") import pytest from fastapi.testclient import TestClient @@ -120,6 +124,7 @@ def client(db_session, db_engine, monkeypatch): monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1") monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None) monkeypatch.setattr("app.main.bootstrap_users", lambda: None) + monkeypatch.setattr("app.main.bootstrap_stale_remote_actions", lambda: None) test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False) monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local) diff --git a/backend/tests/test_client_ip.py b/backend/tests/test_client_ip.py new file mode 100644 index 0000000..718eb1e --- /dev/null +++ b/backend/tests/test_client_ip.py @@ -0,0 +1,26 @@ +"""Tests for client IP resolution behind reverse proxy.""" + +from types import SimpleNamespace + +from app.services.client_ip import client_ip_from_request + + +def _request(*, client_host: str = "10.0.0.5", xff: str | None = None): + headers = {} + if xff is not None: + headers["x-forwarded-for"] = xff + return SimpleNamespace(client=SimpleNamespace(host=client_host), headers=headers) + + +def test_client_ip_without_forwarded_header(): + assert client_ip_from_request(_request(client_host="203.0.113.10")) == "203.0.113.10" + + +def test_client_ip_uses_last_forwarded_hop(): + req = _request(client_host="127.0.0.1", xff="203.0.113.99, 198.51.100.20") + assert client_ip_from_request(req) == "198.51.100.20" + + +def test_client_ip_ignores_spoofed_first_hop(): + req = _request(client_host="127.0.0.1", xff="1.2.3.4, 203.0.113.50") + assert client_ip_from_request(req) == "203.0.113.50" diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 146ecc9..cc8638c 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,9 +1,9 @@ -"""Version and health contract smoke tests (no DB required).""" +"""Version and health contract smoke tests (no DB required).""" from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL def test_version_constants(): - assert APP_VERSION == "0.3.6" + assert APP_VERSION == "0.4.15" assert APP_NAME == "Security Alert Center" - assert APP_VERSION_LABEL == "Security Alert Center v.0.3.6" + assert APP_VERSION_LABEL == "Security Alert Center v.0.4.15" diff --git a/backend/tests/test_ingest_body_limit.py b/backend/tests/test_ingest_body_limit.py new file mode 100644 index 0000000..816d1c5 --- /dev/null +++ b/backend/tests/test_ingest_body_limit.py @@ -0,0 +1,23 @@ +"""Ingest body size limit middleware.""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware + + +def test_ingest_body_size_limit_rejects_large_content_length(): + app = FastAPI() + app.add_middleware(IngestBodySizeLimitMiddleware, max_bytes=128) + + @app.post("/api/v1/events") + def ingest(): + return {"ok": True} + + client = TestClient(app) + response = client.post( + "/api/v1/events", + content=b"x" * 10, + headers={"content-length": "256"}, + ) + assert response.status_code == 413 diff --git a/backend/tests/test_security_hardening.py b/backend/tests/test_security_hardening.py new file mode 100644 index 0000000..4bc1e21 --- /dev/null +++ b/backend/tests/test_security_hardening.py @@ -0,0 +1,52 @@ +"""Security bootstrap and API key hashing.""" + +import hashlib +import pytest + +from app.auth.api_key import _legacy_hash_api_key, hash_api_key, verify_api_key_hash +from app.security_bootstrap import validate_security_settings +from app.config import Settings + + +def test_hash_api_key_uses_hmac(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + from app.config import get_settings + + get_settings.cache_clear() + raw = "sac_example_key" + assert hash_api_key(raw) != _legacy_hash_api_key(raw) + assert verify_api_key_hash(raw, hash_api_key(raw)) + get_settings.cache_clear() + + +def test_verify_api_key_hash_accepts_legacy_sha256(monkeypatch): + monkeypatch.setenv("JWT_SECRET", "unit-test-secret") + from app.config import get_settings + + get_settings.cache_clear() + raw = "sac_legacy_key" + legacy = hashlib.sha256(raw.encode("utf-8")).hexdigest() + assert verify_api_key_hash(raw, legacy) + get_settings.cache_clear() + + +def test_validate_security_settings_rejects_weak_jwt(): + settings = Settings( + jwt_secret="change-me-in-production", + sac_public_url="https://sac.example.com", + cors_origins="https://sac.example.com", + sac_security_enforce=True, + ) + with pytest.raises(RuntimeError, match="JWT_SECRET"): + validate_security_settings(settings) + + +def test_validate_security_settings_rejects_wildcard_cors(monkeypatch): + settings = Settings( + jwt_secret="strong-secret-value", + sac_public_url="https://sac.example.com", + cors_origins="*", + sac_security_enforce=True, + ) + with pytest.raises(RuntimeError, match="CORS_ORIGINS"): + validate_security_settings(settings) diff --git a/backend/tests/test_ssh_connect.py b/backend/tests/test_ssh_connect.py index 2c00898..a06bcf9 100644 --- a/backend/tests/test_ssh_connect.py +++ b/backend/tests/test_ssh_connect.py @@ -1,4 +1,4 @@ -"""SSH connect service tests (paramiko mocked via sys.modules).""" +"""SSH connect service tests (paramiko mocked via sys.modules).""" import sys from unittest.mock import MagicMock @@ -31,6 +31,7 @@ def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup= fake = MagicMock() fake.SSHClient = mock_client_cls fake.AutoAddPolicy = MagicMock() + fake.RejectPolicy = MagicMock() fake.AuthenticationException = _AuthError monkeypatch.setitem(sys.modules, "paramiko", fake) return client @@ -42,26 +43,30 @@ def test_ssh_output_hostname_ignores_motd(): def test_remote_shell_command_non_login_for_sessions(): - cmd = _remote_shell_command("root", "loginctl list-sessions", "secret", login_shell=False) + cmd, needs_pw = _remote_shell_command("root", "loginctl list-sessions", login_shell=False) assert cmd == "bash -c 'loginctl list-sessions'" + assert needs_pw is False def test_remote_shell_command_non_root_probe_has_no_sudo(): - cmd = _remote_shell_command("deploy", "hostname", "secret", need_root=False) + cmd, needs_pw = _remote_shell_command("deploy", "hostname", need_root=False) assert "sudo" not in cmd assert "hostname" in cmd + assert needs_pw is False def test_remote_shell_command_non_root_privileged_uses_sudo_s(): - cmd = _remote_shell_command("deploy", "/opt/scripts/update_ssh_monitor.sh", "secret", need_root=True) + cmd, needs_pw = _remote_shell_command("deploy", "/opt/scripts/update_ssh_monitor.sh", need_root=True) assert "sudo -S" in cmd - assert "secret" in cmd assert "update_ssh_monitor.sh" in cmd + assert needs_pw is True + assert "secret" not in cmd def test_remote_shell_command_root_skips_sudo_even_when_need_root(): - cmd = _remote_shell_command("root", "/opt/scripts/update_ssh_monitor.sh", "secret", need_root=True) + cmd, needs_pw = _remote_shell_command("root", "/opt/scripts/update_ssh_monitor.sh", need_root=True) assert "sudo" not in cmd + assert needs_pw is False def test_probe_ssh_connection_non_root_does_not_use_sudo(monkeypatch): @@ -161,6 +166,7 @@ def test_run_ssh_command_retries_transient_no_existing_session(monkeypatch): fake = MagicMock() fake.SSHClient = MagicMock() fake.AutoAddPolicy = MagicMock() + fake.RejectPolicy = MagicMock() fake.AuthenticationException = _AuthError def make_client(): diff --git a/deploy/env.native.example b/deploy/env.native.example index 3724e0d..2135a41 100644 --- a/deploy/env.native.example +++ b/deploy/env.native.example @@ -1,4 +1,4 @@ -# Native: /opt/security-alert-center/config/sac-api.env +# Native: /opt/security-alert-center/config/sac-api.env # sudo chown sac:sac ... && sudo chmod 600 ... # Пароль в URL — в двойных кавычках. Символ # в пароле допустим только внутри кавычек. # systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE). @@ -12,6 +12,7 @@ SAC_PUBLIC_URL=https://sac.kalinamall.ru # Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS). # SAC_AGENT_BUNDLE_BASE_URL=https://192.168.x.x JWT_SECRET=CHANGE_ME_openssl_rand_hex_32 +SAC_SECURITY_ENFORCE=true # API key для агентов: Authorization: Bearer <ключ> # python3.12 -c "import secrets; print('sac_'+secrets.token_urlsafe(32))" @@ -124,4 +125,12 @@ SAC_FCM_PROJECT_ID= SAC_FCM_SERVICE_ACCOUNT_JSON= SAC_MOBILE_REFRESH_EXPIRE_DAYS=90 -CORS_ORIGINS=* +CORS_ORIGINS=https://sac.kalinamall.ru + +# SSH: verify host keys (RejectPolicy). Add keys to file before remote actions. +# SAC_SSH_KNOWN_HOSTS_FILE=/opt/security-alert-center/config/ssh_known_hosts +# SAC_SSH_AUTO_ADD_HOST_KEY=false + +# WinRM: enable HTTPS listener on clients (5986) before turning on. +# SAC_WINRM_USE_HTTPS=false +# SAC_WINRM_SERVER_CERT_VALIDATION=validate diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 21952b9..219e13e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -const TOKEN_KEY = "sac_token"; +const TOKEN_KEY = "sac_token"; const ROLE_KEY = "sac_role"; export function getToken(): string | null { @@ -31,6 +31,14 @@ export function clearToken(): void { localStorage.removeItem(ROLE_KEY); } +export async function logoutApi(): Promise { + try { + await fetch("/api/v1/auth/logout", { method: "POST", credentials: "same-origin" }); + } catch { + /* ignore network errors on logout */ + } +} + export class ApiError extends Error { status: number; @@ -55,7 +63,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise if (token) { headers.set("Authorization", `Bearer ${token}`); } - const res = await fetch(path, { ...init, headers }); + const res = await fetch(path, { ...init, headers, credentials: "same-origin" }); const text = await res.text(); if (res.status === 401) { if (hadToken) { diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 2a627e0..013ac3c 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -1,4 +1,4 @@ -