fix: create the audit log 0600 in a 0700 directory and refuse symlinks (SEC-11)

This commit is contained in:
Latte committed 2026-10-14 17:18:29 +02:00
1 parent fd5e4b71f3
commit 7307214ab6
4 files changed
+138 -3

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
"""The audit file is private and is never reached through a symlink (SEC-11)."""
from __future__ import annotations
import errno
import os
import stat
from pathlib import Path
import pytest
from pytest_httpx import HTTPXMock
from aegis_gitea_mcp import transports
from aegis_gitea_mcp.audit import AuditLogger, get_audit_logger, validate_audit_log_integrity
from aegis_gitea_mcp.transports import run_http
from tests.test_server_helpers import API, connected, mock_user, server_settings
@pytest.fixture(autouse=True)
def _env(mock_env: None, monkeypatch: pytest.MonkeyPatch) -> None:
"""The audit logger reads the process-wide settings; a startup that passes must not serve."""
def refuse(*args: object, **kwargs: object) -> None:
raise AssertionError("uvicorn.run must not be reached")
monkeypatch.setattr(transports.uvicorn, "run", refuse)
@pytest.fixture
def open_umask() -> object:
"""A permissive umask: what an unprepared host or container would hand out."""
previous = os.umask(0)
yield None
os.umask(previous)
def mode_of(path: Path) -> int:
return stat.S_IMODE(path.stat().st_mode)
async def test_audit_log_is_0600_and_a_new_state_directory_is_0700(
httpx_mock: HTTPXMock, open_umask: object
) -> None:
mock_user(httpx_mock)
httpx_mock.add_response(url=f"{API}/repos/octo/demo", json={})
async with connected() as client:
await client.call_tool("gitea_repo_get", {"owner": "octo", "repo": "demo"})
path = get_audit_logger().log_path
assert mode_of(path) == 0o600
assert mode_of(path.parent) == 0o700
assert validate_audit_log_integrity(path) == (True, [])
def test_an_older_world_readable_log_is_tightened(tmp_path: Path) -> None:
path = tmp_path / "audit.log"
path.write_text("")
path.chmod(0o644)
AuditLogger(path).close()
assert mode_of(path) == 0o600
def test_a_symlink_at_the_audit_path_is_not_followed(tmp_path: Path) -> None:
target = tmp_path / "elsewhere.log"
target.write_text("")
link = tmp_path / "audit.log"
link.symlink_to(target)
with pytest.raises(OSError) as caught:
AuditLogger(link)
assert caught.value.errno == errno.ELOOP
assert target.read_text() == ""
def test_startup_exits_2_for_a_symlinked_audit_path(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
target = tmp_path / "elsewhere.log"
target.write_text("")
link = tmp_path / "state" / "audit.log"
link.parent.mkdir()
link.symlink_to(target)
monkeypatch.setenv("AUDIT_LOG_PATH", str(link))
assert run_http(server_settings(audit_log_path=link)) == 2
assert "symbolic link" in capsys.readouterr().err
assert target.read_text() == ""
def test_startup_exits_2_for_an_audit_path_that_is_a_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
directory = tmp_path / "audit.log"
directory.mkdir()
monkeypatch.setenv("AUDIT_LOG_PATH", str(directory))
assert run_http(server_settings(audit_log_path=directory)) == 2
assert "audit log" in capsys.readouterr().err