hermes-agent/tests/hermes_cli/test_security_audit_startup.py
teknium1 f45ace9318 feat(security): startup security posture audit (warn-on-load)
Surface dangerous host/deployment posture at gateway startup so operators get
the 'you're exposed' signal the June 2026 MCP-config persistence campaign
victims never had. Warn-only — never blocks startup, never raises.

Checks (each independently fail-safe):
- Running as root (POSIX uid 0)
- SSH daemon with PasswordAuthentication enabled (incl. the 'yes' default)
- Running in a container with no persistent volume mount over HERMES_HOME
- Network-accessible API server with no API_SERVER_KEY

New module hermes_cli/security_audit_startup.py; invoked once per process from
start_gateway() right after setup_logging(). Cross-platform (root/SSH checks
no-op on Windows). Idea: @Cthulhu.
2026-06-21 19:05:27 -07:00

163 lines
6.4 KiB
Python

"""Tests for the startup security posture audit (hermes_cli.security_audit_startup)."""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import hermes_cli.security_audit_startup as audit
@pytest.fixture(autouse=True)
def _reset_audit_sentinel():
audit._AUDIT_RAN = False
yield
audit._AUDIT_RAN = False
# ── root check ────────────────────────────────────────────────────────────
def test_root_check_flags_uid_zero(monkeypatch):
monkeypatch.setattr(audit, "_is_root", lambda: True)
msg = audit._running_as_root()
assert msg and "ROOT" in msg
def test_root_check_silent_for_non_root(monkeypatch):
monkeypatch.setattr(audit, "_is_root", lambda: False)
assert audit._running_as_root() is None
# ── SSH password-auth check ─────────────────────────────────────────────────
def test_ssh_password_auth_enabled_explicit_yes(monkeypatch):
monkeypatch.setattr(
audit, "_iter_sshd_config_lines",
lambda: ["PasswordAuthentication yes", "PermitRootLogin no"],
)
msg = audit._ssh_password_auth_enabled()
assert msg and "password authentication is enabled" in msg.lower()
def test_ssh_password_auth_disabled(monkeypatch):
monkeypatch.setattr(
audit, "_iter_sshd_config_lines",
lambda: ["PasswordAuthentication no"],
)
assert audit._ssh_password_auth_enabled() is None
def test_ssh_password_auth_default_is_yes(monkeypatch):
"""No explicit directive → sshd default is 'yes' → warn (with qualifier)."""
monkeypatch.setattr(
audit, "_iter_sshd_config_lines",
lambda: ["PermitRootLogin prohibit-password"],
)
msg = audit._ssh_password_auth_enabled()
assert msg and "default" in msg.lower()
def test_ssh_check_silent_when_no_config(monkeypatch):
"""No sshd config readable (e.g. Windows / SSH not installed) → no finding."""
monkeypatch.setattr(audit, "_iter_sshd_config_lines", lambda: [])
assert audit._ssh_password_auth_enabled() is None
def test_ssh_last_directive_wins(monkeypatch):
monkeypatch.setattr(
audit, "_iter_sshd_config_lines",
lambda: ["PasswordAuthentication yes", "PasswordAuthentication no"],
)
assert audit._ssh_password_auth_enabled() is None
# ── container / volume-mount check ──────────────────────────────────────────
def test_container_no_mount_flags(monkeypatch, tmp_path):
monkeypatch.setattr(audit, "_in_container", lambda: True)
monkeypatch.setattr(audit, "_path_is_mounted", lambda p: False)
msg = audit._container_no_volume_mount(tmp_path / ".hermes")
assert msg and "persistent volume" in msg
def test_container_with_mount_silent(monkeypatch, tmp_path):
monkeypatch.setattr(audit, "_in_container", lambda: True)
monkeypatch.setattr(audit, "_path_is_mounted", lambda p: True)
assert audit._container_no_volume_mount(tmp_path / ".hermes") is None
def test_not_in_container_silent(monkeypatch, tmp_path):
monkeypatch.setattr(audit, "_in_container", lambda: False)
assert audit._container_no_volume_mount(tmp_path / ".hermes") is None
# ── network listener without auth ──────────────────────────────────────────
def test_api_server_network_no_key_flags(monkeypatch):
monkeypatch.delenv("API_SERVER_KEY", raising=False)
cfg = {"platforms": {"api_server": {"enabled": True, "extra": {"host": "0.0.0.0", "key": ""}}}}
findings = audit._network_listener_without_auth(cfg)
assert any("NO API_SERVER_KEY" in f for f in findings)
def test_api_server_loopback_silent(monkeypatch):
cfg = {"platforms": {"api_server": {"enabled": True, "extra": {"host": "127.0.0.1", "key": ""}}}}
assert audit._network_listener_without_auth(cfg) == []
def test_api_server_with_key_silent(monkeypatch):
cfg = {"platforms": {"api_server": {"enabled": True, "extra": {"host": "0.0.0.0", "key": "a-strong-key-1234567890"}}}}
assert audit._network_listener_without_auth(cfg) == []
# ── orchestration + logging ─────────────────────────────────────────────────
def test_run_security_audit_aggregates(monkeypatch, tmp_path):
monkeypatch.setattr(audit, "_is_root", lambda: True)
monkeypatch.setattr(audit, "_iter_sshd_config_lines", lambda: ["PasswordAuthentication yes"])
monkeypatch.setattr(audit, "_in_container", lambda: False)
findings = audit.run_security_audit(hermes_home=tmp_path, config={})
assert len(findings) == 2 # root + ssh
def test_run_security_audit_clean_posture(monkeypatch, tmp_path):
monkeypatch.setattr(audit, "_is_root", lambda: False)
monkeypatch.setattr(audit, "_iter_sshd_config_lines", lambda: ["PasswordAuthentication no"])
monkeypatch.setattr(audit, "_in_container", lambda: False)
assert audit.run_security_audit(hermes_home=tmp_path, config={}) == []
def test_log_startup_security_warnings_emits_and_is_idempotent(monkeypatch, tmp_path, caplog):
import logging
monkeypatch.setattr(audit, "_is_root", lambda: True)
monkeypatch.setattr(audit, "_iter_sshd_config_lines", lambda: [])
monkeypatch.setattr(audit, "_in_container", lambda: False)
with caplog.at_level(logging.WARNING, logger="hermes.security_audit"):
first = audit.log_startup_security_warnings(hermes_home=tmp_path, config={})
assert len(first) == 1
assert any("ROOT" in r.message for r in caplog.records)
# Second call is a no-op (idempotent within a process) unless forced.
second = audit.log_startup_security_warnings(hermes_home=tmp_path, config={})
assert second == []
forced = audit.log_startup_security_warnings(hermes_home=tmp_path, config={}, force=True)
assert len(forced) == 1
def test_audit_never_raises_on_broken_check(monkeypatch, tmp_path):
def _boom():
raise RuntimeError("boom")
monkeypatch.setattr(audit, "_is_root", _boom)
# Must not propagate — the broken check is swallowed, others still run.
findings = audit.run_security_audit(hermes_home=tmp_path, config={})
assert isinstance(findings, list)