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:
PTah
2026-07-07 19:32:12 +10:00
parent 939fe122c5
commit 80320ae698
29 changed files with 452 additions and 160 deletions
+16 -82
View File
@@ -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
+7 -6
View File
@@ -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(
+3 -2
View File
@@ -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)
+4 -3
View File
@@ -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:
+7 -3
View File
@@ -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)
+20 -5
View File
@@ -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(
+37
View File
@@ -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",
)
+8 -1
View File
@@ -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
View File
@@ -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)
+52
View File
@@ -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")
+27
View File
@@ -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]
+2 -10
View File
@@ -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(
+53 -15
View File
@@ -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(
+10 -3
View File
@@ -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()),
+8
View File
@@ -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("_", "\\_")
+2 -2
View File
@@ -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}"
+6 -1
View File
@@ -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)
+26
View File
@@ -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"
+3 -3
View File
@@ -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"
+23
View File
@@ -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
+52
View File
@@ -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)
+12 -6
View File
@@ -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():