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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
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():
|
||||
|
||||
Reference in New Issue
Block a user