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.
This commit is contained in:
+16
-82
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
+13
-4
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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("_", "\\_")
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user