fix: SSH sudo without TTY for non-root Linux admin (0.11.1)
Probe commands run without sudo; privileged update uses sudo -S. Add SSH connect and Linux admin API tests.
This commit is contained in:
@@ -67,11 +67,25 @@ def _truncate_output(text: str) -> str:
|
|||||||
return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)"
|
return text[:SSH_OUTPUT_MAX_LEN] + "\n… (truncated)"
|
||||||
|
|
||||||
|
|
||||||
def _remote_shell_command(user: str, remote_cmd: str) -> str:
|
def _shell_single_quote(value: str) -> str:
|
||||||
safe_cmd = remote_cmd.replace("'", "'\"'\"'")
|
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||||
if user == "root":
|
|
||||||
return f"bash -lc '{safe_cmd}'"
|
|
||||||
return f"bash -lc 'sudo -n {safe_cmd} 2>/dev/null || sudo {safe_cmd}'"
|
def _remote_shell_command(
|
||||||
|
user: str,
|
||||||
|
remote_cmd: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
need_root: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Build remote shell command. Sudo only when need_root and user is not root."""
|
||||||
|
safe_cmd = _shell_single_quote(remote_cmd)
|
||||||
|
if user == "root" or not need_root:
|
||||||
|
return f"bash -lc {safe_cmd}"
|
||||||
|
|
||||||
|
safe_pw = _shell_single_quote(password)
|
||||||
|
inner = f"printf '%s\\n' {safe_pw} | sudo -S -p '' bash -lc {safe_cmd}"
|
||||||
|
return f"bash -lc {_shell_single_quote(inner)}"
|
||||||
|
|
||||||
|
|
||||||
def run_ssh_command(
|
def run_ssh_command(
|
||||||
@@ -82,6 +96,7 @@ def run_ssh_command(
|
|||||||
remote_cmd: str,
|
remote_cmd: str,
|
||||||
connect_timeout_sec: int = 15,
|
connect_timeout_sec: int = 15,
|
||||||
command_timeout_sec: int = 600,
|
command_timeout_sec: int = 600,
|
||||||
|
need_root: bool = False,
|
||||||
) -> SshCommandResult:
|
) -> SshCommandResult:
|
||||||
target = target.strip()
|
target = target.strip()
|
||||||
user = user.strip()
|
user = user.strip()
|
||||||
@@ -95,7 +110,7 @@ def run_ssh_command(
|
|||||||
|
|
||||||
client = paramiko.SSHClient()
|
client = paramiko.SSHClient()
|
||||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
shell_cmd = _remote_shell_command(user, remote_cmd)
|
shell_cmd = _remote_shell_command(user, remote_cmd, password, need_root=need_root)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client.connect(
|
client.connect(
|
||||||
@@ -209,4 +224,5 @@ def run_ssh_monitor_update(
|
|||||||
password=password,
|
password=password,
|
||||||
remote_cmd=script,
|
remote_cmd=script,
|
||||||
command_timeout_sec=900,
|
command_timeout_sec=900,
|
||||||
|
need_root=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.11.0"
|
APP_VERSION = "0.11.1"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ from app.version import APP_NAME, APP_VERSION, APP_VERSION_LABEL
|
|||||||
|
|
||||||
|
|
||||||
def test_version_constants():
|
def test_version_constants():
|
||||||
assert APP_VERSION == "0.11.0"
|
assert APP_VERSION == "0.11.1"
|
||||||
assert APP_NAME == "Security Alert Center"
|
assert APP_NAME == "Security Alert Center"
|
||||||
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.0"
|
assert APP_VERSION_LABEL == "Security Alert Center v.0.11.1"
|
||||||
|
|||||||
@@ -1,16 +1,68 @@
|
|||||||
"""Linux admin settings and SSH host actions."""
|
"""Additional Linux admin API tests."""
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from app.models import Host
|
|
||||||
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
def test_get_linux_admin_settings_env_default(jwt_headers, client, monkeypatch):
|
||||||
from app.services.ssh_connect import SshCommandResult, iter_ssh_targets
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
response = client.get("/api/v1/settings/linux-admin", headers=jwt_headers)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["configured"] is False
|
||||||
|
assert body["source"] == "env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_ssh_test_requires_admin(jwt_monitor_headers, client):
|
||||||
|
response = client.post("/api/v1/hosts/1/actions/ssh-test", headers=jwt_monitor_headers)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_ssh_test_not_linux(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Host
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
host = Host(hostname="PC", os_family="windows", product="rdp-login-monitor", ipv4="10.0.0.1")
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1/hosts/{host.id}/actions/ssh-test", headers=jwt_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "not Linux" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_agent_update_not_configured(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Host
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5")
|
||||||
|
db_session.add(host)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(host)
|
||||||
|
|
||||||
|
response = client.post(f"/api/v1/hosts/{host.id}/actions/agent-update", headers=jwt_headers)
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "not configured" in response.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monkeypatch):
|
def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monkeypatch):
|
||||||
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "")
|
||||||
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "")
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.models.ui_settings import UI_SETTINGS_ROW_ID, UiSettings
|
||||||
|
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
@@ -24,6 +76,7 @@ def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monk
|
|||||||
assert body["configured"] is True
|
assert body["configured"] is True
|
||||||
assert body["user"] == "root"
|
assert body["user"] == "root"
|
||||||
assert body["source"] == "db"
|
assert body["source"] == "db"
|
||||||
|
assert body["password_hint"]
|
||||||
|
|
||||||
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
|
row = db_session.get(UiSettings, UI_SETTINGS_ROW_ID)
|
||||||
assert row is not None
|
assert row is not None
|
||||||
@@ -31,31 +84,50 @@ def test_put_linux_admin_settings_persists(jwt_headers, client, db_session, monk
|
|||||||
assert row.linux_admin_password == "secret-pass"
|
assert row.linux_admin_password == "secret-pass"
|
||||||
|
|
||||||
|
|
||||||
def test_iter_ssh_targets_hostname_before_ip():
|
def test_host_ssh_test_success(jwt_headers, client, db_session, monkeypatch):
|
||||||
host = Host(
|
|
||||||
hostname="ubabuba",
|
|
||||||
os_family="linux",
|
|
||||||
product="ssh-monitor",
|
|
||||||
ipv4="10.0.0.5",
|
|
||||||
)
|
|
||||||
targets = iter_ssh_targets(host)
|
|
||||||
assert targets[0] == "ubabuba"
|
|
||||||
assert targets[-1] == "10.0.0.5"
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_agent_update_via_ssh(jwt_headers, client, db_session, monkeypatch):
|
|
||||||
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||||
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.ssh_connect import SshCommandResult
|
||||||
|
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
host = Host(
|
host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5")
|
||||||
hostname="ubabuba",
|
db_session.add(host)
|
||||||
os_family="linux",
|
db_session.commit()
|
||||||
product="ssh-monitor",
|
db_session.refresh(host)
|
||||||
ipv4="10.0.0.5",
|
|
||||||
)
|
with patch("app.api.v1.hosts.test_ssh_connection") as mock_test:
|
||||||
|
mock_test.return_value = SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="SSH OK, hostname=ubabuba",
|
||||||
|
target="ubabuba",
|
||||||
|
stdout="ubabuba\n",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1/hosts/{host.id}/actions/ssh-test",
|
||||||
|
headers=jwt_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["ok"] is True
|
||||||
|
assert "hostname=ubabuba" in body["message"]
|
||||||
|
assert body["target"] == "ubabuba"
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_agent_update_success(jwt_headers, client, db_session, monkeypatch):
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_USER", "root")
|
||||||
|
monkeypatch.setenv("SAC_LINUX_ADMIN_PASSWORD", "pw")
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import Host
|
||||||
|
from app.services.ssh_connect import SshCommandResult
|
||||||
|
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
host = Host(hostname="ubabuba", os_family="linux", product="ssh-monitor", ipv4="10.0.0.5")
|
||||||
db_session.add(host)
|
db_session.add(host)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
db_session.refresh(host)
|
db_session.refresh(host)
|
||||||
@@ -63,9 +135,9 @@ def test_host_agent_update_via_ssh(jwt_headers, client, db_session, monkeypatch)
|
|||||||
with patch("app.api.v1.hosts.run_ssh_monitor_update") as mock_update:
|
with patch("app.api.v1.hosts.run_ssh_monitor_update") as mock_update:
|
||||||
mock_update.return_value = SshCommandResult(
|
mock_update.return_value = SshCommandResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
message="SSH OK",
|
message="SSH OK (ubabuba), exit 0\nSUMMARY updated",
|
||||||
target="ubabuba",
|
target="ubabuba",
|
||||||
stdout="updated",
|
stdout="SUMMARY updated\n",
|
||||||
exit_code=0,
|
exit_code=0,
|
||||||
)
|
)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""SSH connect service tests (paramiko mocked via sys.modules)."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from app.models import Host
|
||||||
|
from app.services import ssh_connect
|
||||||
|
from app.services.ssh_connect import (
|
||||||
|
HostNotLinuxError,
|
||||||
|
HostTargetMissingError,
|
||||||
|
_remote_shell_command,
|
||||||
|
iter_ssh_targets,
|
||||||
|
run_ssh_command,
|
||||||
|
run_ssh_monitor_update,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AuthError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_paramiko(monkeypatch, *, connect_side_effect=None, exec_setup=None):
|
||||||
|
mock_client_cls = MagicMock()
|
||||||
|
client = MagicMock()
|
||||||
|
if connect_side_effect is not None:
|
||||||
|
client.connect.side_effect = connect_side_effect
|
||||||
|
if exec_setup is not None:
|
||||||
|
exec_setup(client)
|
||||||
|
mock_client_cls.return_value = client
|
||||||
|
|
||||||
|
fake = MagicMock()
|
||||||
|
fake.SSHClient = mock_client_cls
|
||||||
|
fake.AutoAddPolicy = MagicMock()
|
||||||
|
fake.AuthenticationException = _AuthError
|
||||||
|
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_shell_command_non_root_probe_has_no_sudo():
|
||||||
|
cmd = _remote_shell_command("deploy", "hostname", "secret", need_root=False)
|
||||||
|
assert "sudo" not in cmd
|
||||||
|
assert "hostname" in cmd
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert "sudo -S" in cmd
|
||||||
|
assert "secret" in cmd
|
||||||
|
assert "update_ssh_monitor.sh" 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)
|
||||||
|
assert "sudo" not in cmd
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_ssh_connection_non_root_does_not_use_sudo(monkeypatch):
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
def setup(client):
|
||||||
|
stdout = MagicMock()
|
||||||
|
stdout.read.return_value = b"ubabuba\n"
|
||||||
|
stdout.channel.recv_exit_status.return_value = 0
|
||||||
|
stderr = MagicMock()
|
||||||
|
stderr.read.return_value = b""
|
||||||
|
|
||||||
|
def capture_exec(cmd, **kwargs):
|
||||||
|
captured.append(cmd)
|
||||||
|
return (None, stdout, stderr)
|
||||||
|
|
||||||
|
client.exec_command.side_effect = capture_exec
|
||||||
|
|
||||||
|
_install_fake_paramiko(monkeypatch, exec_setup=setup)
|
||||||
|
|
||||||
|
result = ssh_connect.test_ssh_connection(target="10.10.36.9", user="deploy", password="pw")
|
||||||
|
assert result.ok is True
|
||||||
|
assert captured
|
||||||
|
assert "sudo" not in captured[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_ssh_targets_rejects_windows():
|
||||||
|
host = Host(hostname="PC", os_family="windows", product="rdp-login-monitor", ipv4="1.2.3.4")
|
||||||
|
try:
|
||||||
|
iter_ssh_targets(host)
|
||||||
|
raise AssertionError("expected HostNotLinuxError")
|
||||||
|
except HostNotLinuxError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_ssh_targets_requires_address():
|
||||||
|
host = Host(hostname="", os_family="linux", product="ssh-monitor", ipv4=None)
|
||||||
|
try:
|
||||||
|
iter_ssh_targets(host)
|
||||||
|
raise AssertionError("expected HostTargetMissingError")
|
||||||
|
except HostTargetMissingError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_ssh_connection_success(monkeypatch):
|
||||||
|
def setup(client):
|
||||||
|
stdout = MagicMock()
|
||||||
|
stdout.read.return_value = b"ubabuba\n"
|
||||||
|
stdout.channel.recv_exit_status.return_value = 0
|
||||||
|
stderr = MagicMock()
|
||||||
|
stderr.read.return_value = b""
|
||||||
|
client.exec_command.return_value = (None, stdout, stderr)
|
||||||
|
|
||||||
|
client = _install_fake_paramiko(monkeypatch, exec_setup=setup)
|
||||||
|
|
||||||
|
result = ssh_connect.test_ssh_connection(target="ubabuba", user="root", password="pw")
|
||||||
|
assert result.ok is True
|
||||||
|
assert "hostname=ubabuba" in result.message
|
||||||
|
client.connect.assert_called_once()
|
||||||
|
client.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ssh_command_auth_failure(monkeypatch):
|
||||||
|
client = _install_fake_paramiko(monkeypatch, connect_side_effect=_AuthError("bad creds"))
|
||||||
|
|
||||||
|
result = run_ssh_command(target="10.0.0.1", user="root", password="wrong", remote_cmd="hostname")
|
||||||
|
assert result.ok is False
|
||||||
|
assert "auth failed" in result.message.lower()
|
||||||
|
client.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ssh_monitor_update_missing_script(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ssh_connect,
|
||||||
|
"run_ssh_command",
|
||||||
|
lambda **kwargs: ssh_connect.SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="ok",
|
||||||
|
target="h",
|
||||||
|
stdout="missing\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw")
|
||||||
|
assert result.ok is False
|
||||||
|
assert "not found" in result.message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_ssh_monitor_update_runs_script(monkeypatch):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def fake_run(**kwargs):
|
||||||
|
nonlocal calls
|
||||||
|
calls += 1
|
||||||
|
if calls == 1:
|
||||||
|
return ssh_connect.SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="ok",
|
||||||
|
target="h",
|
||||||
|
stdout="ready\n",
|
||||||
|
)
|
||||||
|
assert kwargs["remote_cmd"] == "/opt/scripts/update_ssh_monitor.sh"
|
||||||
|
assert kwargs.get("need_root") is True
|
||||||
|
return ssh_connect.SshCommandResult(
|
||||||
|
ok=True,
|
||||||
|
message="SSH OK",
|
||||||
|
target="ubabuba",
|
||||||
|
stdout="SUMMARY updated\n",
|
||||||
|
exit_code=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(ssh_connect, "run_ssh_command", fake_run)
|
||||||
|
|
||||||
|
result = run_ssh_monitor_update(target="ubabuba", user="root", password="pw")
|
||||||
|
assert result.ok is True
|
||||||
|
assert calls == 2
|
||||||
@@ -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.11.0";
|
export const APP_VERSION = "0.11.1";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user