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 fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from app.auth.jwt_auth import CurrentUser, create_access_token, get_current_user
|
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.config import get_settings
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
|
||||||
from app.services.login_rate_limit import (
|
from app.services.login_rate_limit import (
|
||||||
|
|
||||||
ensure_login_allowed,
|
ensure_login_allowed,
|
||||||
|
|
||||||
record_login_failure,
|
record_login_failure,
|
||||||
|
|
||||||
record_login_success,
|
record_login_success,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from app.services.user_auth import authenticate_user
|
from app.services.user_auth import authenticate_user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
|
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
|
|
||||||
username: str
|
username: str
|
||||||
|
|
||||||
role: str
|
role: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> JSONResponse:
|
||||||
def login(body: LoginRequest, request: Request, db: Session = Depends(get_db)) -> TokenResponse:
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
if not settings.sac_admin_password and not _has_any_user(db):
|
if not settings.sac_admin_password and not _has_any_user(db):
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
||||||
status_code=503,
|
status_code=503,
|
||||||
|
|
||||||
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
detail="SAC UI users are not configured (run alembic upgrade and set SAC_ADMIN_PASSWORD for bootstrap)",
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
ip_address = ensure_login_allowed(db, request)
|
ip_address = ensure_login_allowed(db, request)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
user = authenticate_user(db, body.username, body.password)
|
user = authenticate_user(db, body.username, body.password)
|
||||||
|
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|
||||||
record_login_failure(db, ip_address=ip_address, username=body.username)
|
record_login_failure(db, ip_address=ip_address, username=body.username)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
record_login_success(db, ip_address=ip_address, username=user.username)
|
record_login_success(db, ip_address=ip_address, username=user.username)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
token = create_access_token(user.username, user.role)
|
token = create_access_token(user.username, user.role)
|
||||||
|
payload = TokenResponse(
|
||||||
return TokenResponse(
|
|
||||||
|
|
||||||
access_token=token,
|
access_token=token,
|
||||||
|
|
||||||
username=user.username,
|
username=user.username,
|
||||||
|
|
||||||
role=user.role,
|
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)
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
|
||||||
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
def me(current_user: CurrentUser = Depends(get_current_user)) -> MeResponse:
|
||||||
|
|
||||||
return MeResponse(username=current_user.username, role=current_user.role)
|
return MeResponse(username=current_user.username, role=current_user.role)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _has_any_user(db: Session) -> bool:
|
def _has_any_user(db: Session) -> bool:
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
||||||
count = db.scalar(select(func.count()).select_from(User)) or 0
|
count = db.scalar(select(func.count()).select_from(User)) or 0
|
||||||
|
|
||||||
return count > 0
|
return count > 0
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.config import get_settings
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
from app.schemas.list_models import EventDetail, EventListResponse, EventSummary
|
||||||
|
from app.utils.sql_like import escape_ilike_pattern
|
||||||
from app.services.agent_commands import (
|
from app.services.agent_commands import (
|
||||||
command_response_fields,
|
command_response_fields,
|
||||||
command_to_dict,
|
command_to_dict,
|
||||||
@@ -185,9 +186,9 @@ def list_events(
|
|||||||
stmt = stmt.where(Event.host_id == host_id)
|
stmt = stmt.where(Event.host_id == host_id)
|
||||||
count_stmt = count_stmt.where(Event.host_id == host_id)
|
count_stmt = count_stmt.where(Event.host_id == host_id)
|
||||||
if hostname:
|
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="\\"))
|
||||||
dt_from = _parse_optional_dt(from_time)
|
dt_from = _parse_optional_dt(from_time)
|
||||||
dt_to = _parse_optional_dt(to_time, end_of_day=True)
|
dt_to = _parse_optional_dt(to_time, end_of_day=True)
|
||||||
if dt_from:
|
if dt_from:
|
||||||
@@ -197,9 +198,9 @@ def list_events(
|
|||||||
stmt = stmt.where(Event.occurred_at <= dt_to)
|
stmt = stmt.where(Event.occurred_at <= dt_to)
|
||||||
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
|
count_stmt = count_stmt.where(Event.occurred_at <= dt_to)
|
||||||
if q:
|
if q:
|
||||||
like = f"%{q}%"
|
like = f"%{escape_ilike_pattern(q)}%"
|
||||||
stmt = stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
|
stmt = stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||||
count_stmt = count_stmt.where(Event.summary.ilike(like) | Event.title.ilike(like))
|
count_stmt = count_stmt.where(Event.summary.ilike(like, escape="\\") | Event.title.ilike(like, escape="\\"))
|
||||||
|
|
||||||
total = db.scalar(count_stmt) or 0
|
total = db.scalar(count_stmt) or 0
|
||||||
rows = db.scalars(
|
rows = db.scalars(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.config import get_settings
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import Event, Host
|
from app.models import Event, Host
|
||||||
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
from app.schemas.list_models import HostDetail, HostListResponse, HostSummary
|
||||||
|
from app.utils.sql_like import escape_ilike_pattern
|
||||||
from app.services.agent_version import (
|
from app.services.agent_version import (
|
||||||
latest_agent_versions_by_product,
|
latest_agent_versions_by_product,
|
||||||
reference_agent_versions_by_product,
|
reference_agent_versions_by_product,
|
||||||
@@ -101,8 +102,8 @@ def list_hosts(
|
|||||||
stmt = stmt.where(Host.product == product)
|
stmt = stmt.where(Host.product == product)
|
||||||
count_stmt = count_stmt.where(Host.product == product)
|
count_stmt = count_stmt.where(Host.product == product)
|
||||||
if hostname:
|
if hostname:
|
||||||
like = f"%{hostname}%"
|
like = f"%{escape_ilike_pattern(hostname)}%"
|
||||||
host_match = Host.hostname.ilike(like) | Host.display_name.ilike(like)
|
host_match = Host.hostname.ilike(like, escape="\\") | Host.display_name.ilike(like, escape="\\")
|
||||||
stmt = stmt.where(host_match)
|
stmt = stmt.where(host_match)
|
||||||
count_stmt = count_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.models import Event, Host, Problem, ProblemEvent
|
||||||
from app.services.event_type_visibility import get_hidden_event_types
|
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:
|
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:
|
if created_within_hours is not None:
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
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 fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth.jwt_auth import verify_access_token
|
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.database import SessionLocal, get_db
|
||||||
from app.models import Event, Problem
|
from app.models import Event, Problem
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
@@ -67,9 +68,12 @@ async def _sse_generator():
|
|||||||
|
|
||||||
|
|
||||||
def _sse_auth(
|
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),
|
db: Session = Depends(get_db),
|
||||||
) -> str:
|
) -> 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)
|
return verify_access_token(token, db=db)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import hmac
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Security
|
from fastapi import Depends, HTTPException, Security
|
||||||
@@ -6,16 +7,28 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import ApiKey
|
from app.models import ApiKey
|
||||||
|
|
||||||
security_scheme = HTTPBearer(auto_error=False)
|
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()
|
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]:
|
def generate_api_key() -> tuple[str, str, str]:
|
||||||
"""Returns (full_key, prefix, hash)."""
|
"""Returns (full_key, prefix, hash)."""
|
||||||
raw = f"sac_{secrets.token_urlsafe(32)}"
|
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:
|
def verify_api_key(db: Session, raw_key: str) -> bool:
|
||||||
key_hash = hash_api_key(raw_key)
|
rows = db.scalars(select(ApiKey).where(ApiKey.is_active.is_(True))).all()
|
||||||
row = db.scalar(select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active.is_(True)))
|
for row in rows:
|
||||||
return row is not None
|
if verify_api_key_hash(raw_key, row.key_hash):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_api_key_auth(
|
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 functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -42,6 +42,9 @@ class Settings(BaseSettings):
|
|||||||
jwt_secret: str = "change-me-in-production"
|
jwt_secret: str = "change-me-in-production"
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_expire_minutes: int = 60 * 24
|
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_bootstrap_api_key: str = ""
|
||||||
sac_admin_username: str = "admin"
|
sac_admin_username: str = "admin"
|
||||||
@@ -116,10 +119,14 @@ class Settings(BaseSettings):
|
|||||||
# Windows admin for agent qwinsta/logoff (domain-wide)
|
# Windows admin for agent qwinsta/logoff (domain-wide)
|
||||||
sac_win_admin_user: str = ""
|
sac_win_admin_user: str = ""
|
||||||
sac_win_admin_password: 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)
|
# Linux SSH admin for remote agent update (fallback SSH, phase 5)
|
||||||
sac_linux_admin_user: str = ""
|
sac_linux_admin_user: str = ""
|
||||||
sac_linux_admin_password: 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)
|
# Agent updates (mode gpo|sac, recommended versions, WinRM fallback script)
|
||||||
sac_agent_update_mode: str = "gpo"
|
sac_agent_update_mode: str = "gpo"
|
||||||
|
|||||||
+13
-4
@@ -1,4 +1,4 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
from pathlib import Path
|
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.auth.api_key import hash_api_key
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
|
from app.middleware.ingest_body_limit import IngestBodySizeLimitMiddleware
|
||||||
from app.models import ApiKey
|
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.services.user_auth import bootstrap_admin_user
|
||||||
from app.version import APP_VERSION, APP_VERSION_LABEL
|
from app.version import APP_VERSION, APP_VERSION_LABEL
|
||||||
|
|
||||||
@@ -67,6 +69,9 @@ def bootstrap_stale_remote_actions() -> None:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI):
|
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)
|
logger.info("%s — application startup (version %s)", APP_VERSION_LABEL, APP_VERSION)
|
||||||
bootstrap_api_key()
|
bootstrap_api_key()
|
||||||
bootstrap_users()
|
bootstrap_users()
|
||||||
@@ -74,7 +79,6 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
stop_scan = asyncio.Event()
|
stop_scan = asyncio.Event()
|
||||||
scan_task: asyncio.Task | None = None
|
scan_task: asyncio.Task | None = None
|
||||||
settings = get_settings()
|
|
||||||
if settings.sac_host_silence_scan_enabled:
|
if settings.sac_host_silence_scan_enabled:
|
||||||
from app.jobs.host_silence_background import host_silence_scan_loop
|
from app.jobs.host_silence_background import host_silence_scan_loop
|
||||||
|
|
||||||
@@ -108,13 +112,18 @@ def create_app() -> FastAPI:
|
|||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
|
wildcard = origins == ["*"]
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins if origins != ["*"] else ["*"],
|
allow_origins=origins if not wildcard else ["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=not wildcard,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
IngestBodySizeLimitMiddleware,
|
||||||
|
max_bytes=settings.sac_ingest_max_body_bytes,
|
||||||
|
)
|
||||||
from app.api.v1.health import router as health_router
|
from app.api.v1.health import router as health_router
|
||||||
|
|
||||||
app.include_router(api_router, prefix="/api/v1")
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models.login_attempt import LoginAttempt
|
from app.models.login_attempt import LoginAttempt
|
||||||
|
from app.services.client_ip import client_ip_from_request
|
||||||
from app.services.login_security_settings import (
|
from app.services.login_security_settings import (
|
||||||
get_effective_login_security_config,
|
get_effective_login_security_config,
|
||||||
is_ip_login_whitelisted,
|
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:
|
def _failure_count(db: Session, ip_address: str, *, since: datetime) -> int:
|
||||||
return int(
|
return int(
|
||||||
db.scalar(
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
from app.models import Host
|
from app.models import Host
|
||||||
|
|
||||||
SSH_MONITOR_UPDATE_SCRIPT = "/opt/scripts/update_ssh_monitor.sh"
|
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(
|
def _remote_shell_command(
|
||||||
user: str,
|
user: str,
|
||||||
remote_cmd: str,
|
remote_cmd: str,
|
||||||
password: str,
|
|
||||||
*,
|
*,
|
||||||
need_root: bool = False,
|
need_root: bool = False,
|
||||||
login_shell: bool = True,
|
login_shell: bool = True,
|
||||||
) -> str:
|
) -> tuple[str, bool]:
|
||||||
"""Build remote shell command. Sudo only when need_root and user is not root."""
|
"""Build remote shell command. Returns (command, needs_sudo_password_on_stdin)."""
|
||||||
safe_cmd = _shell_single_quote(remote_cmd)
|
safe_cmd = _shell_single_quote(remote_cmd)
|
||||||
bash_flag = "lc" if login_shell else "c"
|
bash_flag = "lc" if login_shell else "c"
|
||||||
if user == "root" or not need_root:
|
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}"
|
def _configure_ssh_client(client, target: str) -> None:
|
||||||
return f"bash -{bash_flag} {_shell_single_quote(inner)}"
|
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(
|
def run_ssh_command(
|
||||||
@@ -224,10 +260,9 @@ def run_ssh_command(
|
|||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError("paramiko is not installed on SAC server") from 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,
|
user,
|
||||||
remote_cmd,
|
remote_cmd,
|
||||||
password,
|
|
||||||
need_root=need_root,
|
need_root=need_root,
|
||||||
login_shell=login_shell,
|
login_shell=login_shell,
|
||||||
)
|
)
|
||||||
@@ -237,8 +272,8 @@ def run_ssh_command(
|
|||||||
|
|
||||||
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
|
for attempt in range(1, _SSH_CONNECT_ATTEMPTS + 1):
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
|
||||||
try:
|
try:
|
||||||
|
_configure_ssh_client(client, target)
|
||||||
_connect_ssh_client(
|
_connect_ssh_client(
|
||||||
client,
|
client,
|
||||||
target=target,
|
target=target,
|
||||||
@@ -246,10 +281,13 @@ def run_ssh_command(
|
|||||||
password=password,
|
password=password,
|
||||||
connect_timeout_sec=connect_timeout_sec,
|
connect_timeout_sec=connect_timeout_sec,
|
||||||
)
|
)
|
||||||
_stdin, stdout, stderr = client.exec_command(shell_cmd, timeout=command_timeout_sec)
|
exit_code, out_text, err_text = _exec_remote_command(
|
||||||
exit_code = stdout.channel.recv_exit_status()
|
client,
|
||||||
out_text = _truncate_output(stdout.read().decode("utf-8", errors="replace"))
|
shell_cmd=shell_cmd,
|
||||||
err_text = _truncate_output(stderr.read().decode("utf-8", errors="replace"))
|
password=password,
|
||||||
|
needs_sudo_password=needs_sudo_password,
|
||||||
|
command_timeout_sec=command_timeout_sec,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
except paramiko.AuthenticationException:
|
except paramiko.AuthenticationException:
|
||||||
return SshCommandResult(
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -92,13 +92,20 @@ def _winrm_session(
|
|||||||
|
|
||||||
operation_timeout_sec = max(5, timeout_sec)
|
operation_timeout_sec = max(5, timeout_sec)
|
||||||
read_timeout_sec = operation_timeout_sec + 15
|
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(
|
session = winrm.Session(
|
||||||
endpoint,
|
endpoint,
|
||||||
auth=(user, password),
|
auth=(user, password),
|
||||||
transport="ntlm",
|
transport="ntlm",
|
||||||
read_timeout_sec=read_timeout_sec,
|
read_timeout_sec=read_timeout_sec,
|
||||||
operation_timeout_sec=operation_timeout_sec,
|
operation_timeout_sec=operation_timeout_sec,
|
||||||
|
server_cert_validation=cert_validation,
|
||||||
)
|
)
|
||||||
return session, winrm
|
return session, winrm
|
||||||
|
|
||||||
@@ -540,7 +547,7 @@ def run_winrm_rdp_monitor_update(
|
|||||||
ok=False,
|
ok=False,
|
||||||
message=(
|
message=(
|
||||||
f"Failed to download RDP bundle on client: {download.message} "
|
f"Failed to download RDP bundle on client: {download.message} "
|
||||||
f"(URL: {bundle_url})"
|
"(bundle URL omitted from logs)"
|
||||||
),
|
),
|
||||||
target=target,
|
target=target,
|
||||||
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
|
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_NAME = "Security Alert Center"
|
||||||
APP_VERSION = "0.4.14"
|
APP_VERSION = "0.4.15"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
"""SQLite in-memory fixtures for API tests."""
|
"""SQLite in-memory fixtures for API tests."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Must be set before app.database imports create_engine
|
# Must be set before app.database imports create_engine
|
||||||
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||||
os.environ.setdefault("SAC_BOOTSTRAP_API_KEY", "sac_test_key_for_pytest_only")
|
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
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -120,6 +124,7 @@ def client(db_session, db_engine, monkeypatch):
|
|||||||
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
|
monkeypatch.setenv("SAC_REMOTE_ACTION_INLINE", "1")
|
||||||
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
monkeypatch.setattr("app.main.bootstrap_api_key", lambda: None)
|
||||||
monkeypatch.setattr("app.main.bootstrap_users", 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)
|
test_session_local = sessionmaker(bind=db_engine, autocommit=False, autoflush=False)
|
||||||
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
|
monkeypatch.setattr("app.services.host_remote_actions.SessionLocal", test_session_local)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.3.6"
|
assert APP_VERSION == "0.4.15"
|
||||||
assert APP_NAME == "Security Alert Center"
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""SSH connect service tests (paramiko mocked via sys.modules)."""
|
"""SSH connect service tests (paramiko mocked via sys.modules)."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
@@ -31,6 +31,7 @@ def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup=
|
|||||||
fake = MagicMock()
|
fake = MagicMock()
|
||||||
fake.SSHClient = mock_client_cls
|
fake.SSHClient = mock_client_cls
|
||||||
fake.AutoAddPolicy = MagicMock()
|
fake.AutoAddPolicy = MagicMock()
|
||||||
|
fake.RejectPolicy = MagicMock()
|
||||||
fake.AuthenticationException = _AuthError
|
fake.AuthenticationException = _AuthError
|
||||||
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
||||||
return client
|
return client
|
||||||
@@ -42,26 +43,30 @@ def test_ssh_output_hostname_ignores_motd():
|
|||||||
|
|
||||||
|
|
||||||
def test_remote_shell_command_non_login_for_sessions():
|
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 cmd == "bash -c 'loginctl list-sessions'"
|
||||||
|
assert needs_pw is False
|
||||||
|
|
||||||
|
|
||||||
def test_remote_shell_command_non_root_probe_has_no_sudo():
|
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 "sudo" not in cmd
|
||||||
assert "hostname" in cmd
|
assert "hostname" in cmd
|
||||||
|
assert needs_pw is False
|
||||||
|
|
||||||
|
|
||||||
def test_remote_shell_command_non_root_privileged_uses_sudo_s():
|
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 "sudo -S" in cmd
|
||||||
assert "secret" in cmd
|
|
||||||
assert "update_ssh_monitor.sh" 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():
|
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 "sudo" not in cmd
|
||||||
|
assert needs_pw is False
|
||||||
|
|
||||||
|
|
||||||
def test_probe_ssh_connection_non_root_does_not_use_sudo(monkeypatch):
|
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 = MagicMock()
|
||||||
fake.SSHClient = MagicMock()
|
fake.SSHClient = MagicMock()
|
||||||
fake.AutoAddPolicy = MagicMock()
|
fake.AutoAddPolicy = MagicMock()
|
||||||
|
fake.RejectPolicy = MagicMock()
|
||||||
fake.AuthenticationException = _AuthError
|
fake.AuthenticationException = _AuthError
|
||||||
|
|
||||||
def make_client():
|
def make_client():
|
||||||
|
|||||||
@@ -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 ...
|
# sudo chown sac:sac ... && sudo chmod 600 ...
|
||||||
# Пароль в URL — в двойных кавычках. Символ # в пароле допустим только внутри кавычек.
|
# Пароль в URL — в двойных кавычках. Символ # в пароле допустим только внутри кавычек.
|
||||||
# systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE).
|
# systemd НЕ парсит этот файл — читает приложение (SAC_CONFIG_FILE).
|
||||||
@@ -12,6 +12,7 @@ SAC_PUBLIC_URL=https://sac.kalinamall.ru
|
|||||||
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
|
# Опционально: другой базовый URL для WinRM-скачивания RDP bundle с ПК (LAN / split-DNS).
|
||||||
# SAC_AGENT_BUNDLE_BASE_URL=https://192.168.x.x
|
# SAC_AGENT_BUNDLE_BASE_URL=https://192.168.x.x
|
||||||
JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
|
JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||||
|
SAC_SECURITY_ENFORCE=true
|
||||||
|
|
||||||
# API key для агентов: Authorization: Bearer <ключ>
|
# API key для агентов: Authorization: Bearer <ключ>
|
||||||
# python3.12 -c "import secrets; print('sac_'+secrets.token_urlsafe(32))"
|
# 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_FCM_SERVICE_ACCOUNT_JSON=
|
||||||
SAC_MOBILE_REFRESH_EXPIRE_DAYS=90
|
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
|
||||||
|
|||||||
+10
-2
@@ -1,4 +1,4 @@
|
|||||||
const TOKEN_KEY = "sac_token";
|
const TOKEN_KEY = "sac_token";
|
||||||
const ROLE_KEY = "sac_role";
|
const ROLE_KEY = "sac_role";
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
@@ -31,6 +31,14 @@ export function clearToken(): void {
|
|||||||
localStorage.removeItem(ROLE_KEY);
|
localStorage.removeItem(ROLE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function logoutApi(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fetch("/api/v1/auth/logout", { method: "POST", credentials: "same-origin" });
|
||||||
|
} catch {
|
||||||
|
/* ignore network errors on logout */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
|
|
||||||
@@ -55,7 +63,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
if (token) {
|
if (token) {
|
||||||
headers.set("Authorization", `Bearer ${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();
|
const text = await res.text();
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
if (hadToken) {
|
if (hadToken) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="sac-sidebar">
|
<aside class="sac-sidebar">
|
||||||
<div class="sac-sidebar-brand">
|
<div class="sac-sidebar-brand">
|
||||||
<img src="/sac-icon.png" alt="" class="sac-brand-logo" width="40" height="40" />
|
<img src="/sac-icon.png" alt="" class="sac-brand-logo" width="40" height="40" />
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { clearToken, isAdmin } from "../api";
|
import { clearToken, isAdmin, logoutApi } from "../api";
|
||||||
import SidebarSystemStats from "./SidebarSystemStats.vue";
|
import SidebarSystemStats from "./SidebarSystemStats.vue";
|
||||||
import { useSacVersion } from "../composables/useSacVersion";
|
import { useSacVersion } from "../composables/useSacVersion";
|
||||||
import {
|
import {
|
||||||
@@ -82,8 +82,10 @@ function isActive(item: (typeof navItems.value)[number]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
clearToken();
|
void logoutApi().finally(() => {
|
||||||
router.push("/login");
|
clearToken();
|
||||||
|
router.push("/login");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { onMounted, onUnmounted, ref } from "vue";
|
||||||
import { getToken } from "../api";
|
import { getToken } from "../api";
|
||||||
|
|
||||||
export interface SacDashboardStreamMessage {
|
export interface SacDashboardStreamMessage {
|
||||||
@@ -41,7 +41,7 @@ export function useSacLiveEventStream(onNewEvent: () => void) {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
|
const url = `/api/v1/stream/events`;
|
||||||
eventSource = new EventSource(url);
|
eventSource = new EventSource(url);
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
|
||||||
export const APP_NAME = "Security Alert Center";
|
export const APP_NAME = "Security Alert Center";
|
||||||
export const APP_VERSION = "0.4.11";
|
export const APP_VERSION = "0.4.15";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="overview-page">
|
<div class="overview-page">
|
||||||
<h1>Обзор</h1>
|
<h1>Обзор</h1>
|
||||||
|
|
||||||
@@ -556,7 +556,7 @@ function connectLive() {
|
|||||||
|
|
||||||
eventSource?.close();
|
eventSource?.close();
|
||||||
|
|
||||||
const url = `/api/v1/stream/events?token=${encodeURIComponent(token)}`;
|
const url = `/api/v1/stream/events`;
|
||||||
|
|
||||||
eventSource = new EventSource(url);
|
eventSource = new EventSource(url);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user