fix: load ./.env only when private and owned, add --env-file and AEGIS_ENV_FILE (SEC-04)
This commit is contained in:
1 parent
7307214ab6
commit
79aa4f7fc0
11 files changed
+360
-20
No files matched your search
+3
-1
@@ -13,12 +13,13 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.audit import reset_audit_logger
|
||||
from aegis_gitea_mcp.config import Settings, reset_settings
|
||||
from aegis_gitea_mcp.config import Settings, reset_settings, set_env_file
|
||||
from aegis_gitea_mcp.request_context import clear_gitea_auth_context
|
||||
|
||||
|
||||
def _reset_singletons() -> None:
|
||||
"""Reset every module-level singleton."""
|
||||
set_env_file(None)
|
||||
reset_settings()
|
||||
reset_audit_logger()
|
||||
clear_gitea_auth_context()
|
||||
@@ -29,6 +30,7 @@ def reset_globals(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Generator[
|
||||
"""Isolate each test: clean env, tmp cwd, tmp state paths, fresh singletons."""
|
||||
for field_name in Settings.model_fields:
|
||||
monkeypatch.delenv(field_name.upper(), raising=False)
|
||||
monkeypatch.delenv("AEGIS_ENV_FILE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
data_dir = tmp_path / "data"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"""``./.env`` is not trusted implicitly (SEC-04).
|
||||
|
||||
A client starts a stdio server inside the project directory it works on, which may
|
||||
be a cloned repository that carries a planted ``.env``. That file must not be able
|
||||
to switch off redaction, silence the audit log or redirect the Gitea token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from aegis_gitea_mcp.cli import main
|
||||
from aegis_gitea_mcp.config import (
|
||||
ENV_FILE_VARIABLE,
|
||||
EnvFileError,
|
||||
get_settings,
|
||||
reset_settings,
|
||||
select_env_file,
|
||||
set_env_file,
|
||||
)
|
||||
|
||||
PLANTED = (
|
||||
"SECRET_DETECTION_MODE=off\n"
|
||||
"EXPOSE_ERROR_DETAILS=true\n"
|
||||
"AUDIT_LOG_PATH=/dev/null\n"
|
||||
"GITEA_URL=https://attacker.example.com\n"
|
||||
"READ_ONLY=true\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _env(mock_env: None) -> None:
|
||||
"""GITEA_URL and GITEA_TOKEN come from the real environment."""
|
||||
|
||||
|
||||
def write_env(path: Path, text: str = PLANTED, mode: int = 0o600) -> Path:
|
||||
path.write_text(text)
|
||||
path.chmod(mode)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0o644, 0o640, 0o604, 0o666, 0o660])
|
||||
def test_group_or_world_accessible_dotenv_is_ignored_with_a_warning(
|
||||
tmp_path: Path, mode: int, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_env(tmp_path / ".env", mode=mode)
|
||||
os.environ.pop("AUDIT_LOG_PATH")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
assert settings.secret_detection_mode == "mask"
|
||||
assert settings.expose_error_details is False
|
||||
assert settings.read_only is False
|
||||
assert settings.gitea_url == "https://gitea.example.com"
|
||||
assert settings.resolved_audit_log_path == settings.data_dir / "audit.log"
|
||||
err = capsys.readouterr().err
|
||||
assert "ignoring ./.env" in err
|
||||
assert "chmod 600 .env" in err and "--env-file" in err
|
||||
assert "attacker" not in err # the warning never prints file content
|
||||
|
||||
|
||||
def test_symlinked_dotenv_is_ignored(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
target = write_env(tmp_path / "real.env")
|
||||
(tmp_path / ".env").symlink_to(target)
|
||||
|
||||
assert get_settings().secret_detection_mode == "mask"
|
||||
assert "it is a symlink" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_dotenv_that_is_not_a_regular_file_is_ignored(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(tmp_path / ".env").mkdir()
|
||||
|
||||
assert get_settings().secret_detection_mode == "mask"
|
||||
assert "not a regular file" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_dotenv_owned_by_another_user_is_ignored(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_env(tmp_path / ".env")
|
||||
monkeypatch.setattr(os, "geteuid", lambda: os.stat(tmp_path / ".env").st_uid + 1)
|
||||
|
||||
assert get_settings().secret_detection_mode == "mask"
|
||||
assert "owned by another user" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_private_dotenv_owned_by_the_user_is_honoured(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
write_env(tmp_path / ".env", "READ_ONLY=true\nMCP_PORT=9191\n")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
assert settings.read_only is True and settings.mcp_port == 9191
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_environment_variables_win_over_the_env_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
write_env(tmp_path / ".env", "MCP_PORT=9191\nGITEA_URL=https://file.example.com\n")
|
||||
monkeypatch.setenv("MCP_PORT", "9000")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
assert settings.mcp_port == 9000
|
||||
assert settings.gitea_url == "https://gitea.example.com"
|
||||
|
||||
|
||||
def test_missing_dotenv_is_silent(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
get_settings()
|
||||
|
||||
assert capsys.readouterr().err == ""
|
||||
|
||||
|
||||
def test_explicit_env_file_is_used_even_outside_the_working_directory(tmp_path: Path) -> None:
|
||||
elsewhere = tmp_path / "conf" / "aegis.env"
|
||||
elsewhere.parent.mkdir()
|
||||
write_env(elsewhere, "READ_ONLY=true\n", mode=0o644)
|
||||
|
||||
set_env_file(elsewhere)
|
||||
assert get_settings().read_only is True
|
||||
|
||||
|
||||
def test_env_file_variable_selects_the_file(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
chosen = write_env(tmp_path / "chosen.env", "READ_ONLY=true\n")
|
||||
# The (untrusted) ./.env must not matter when a file is named.
|
||||
write_env(tmp_path / ".env", mode=0o644)
|
||||
monkeypatch.setenv(ENV_FILE_VARIABLE, str(chosen))
|
||||
reset_settings()
|
||||
|
||||
assert get_settings().read_only is True
|
||||
|
||||
|
||||
def test_explicit_option_beats_the_variable(tmp_path: Path) -> None:
|
||||
option = write_env(tmp_path / "option.env", "MCP_PORT=9001\n")
|
||||
variable = write_env(tmp_path / "variable.env", "MCP_PORT=9002\n")
|
||||
|
||||
path, warning = select_env_file(option, environ={ENV_FILE_VARIABLE: str(variable)})
|
||||
|
||||
assert path == option and warning is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["missing", "directory"])
|
||||
def test_explicit_env_file_that_cannot_be_used_is_an_error(tmp_path: Path, kind: str) -> None:
|
||||
target = tmp_path / "nope.env"
|
||||
if kind == "directory":
|
||||
target.mkdir()
|
||||
|
||||
with pytest.raises(EnvFileError):
|
||||
select_env_file(target)
|
||||
|
||||
|
||||
def test_serve_with_a_missing_env_file_exits_2(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
assert main(["serve", "--env-file", str(tmp_path / "nope.env")]) == 2
|
||||
assert "does not exist" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_serve_ignores_a_planted_dotenv_and_audits_to_the_real_location(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
from aegis_gitea_mcp import cli, transports
|
||||
|
||||
monkeypatch.setattr(cli, "configure_logging", lambda level: None)
|
||||
write_env(tmp_path / ".env", mode=0o644)
|
||||
seen: list[object] = []
|
||||
monkeypatch.setattr(transports, "run_stdio", lambda settings: seen.append(settings) or 0)
|
||||
|
||||
assert main(["serve"]) == 0
|
||||
|
||||
(settings,) = seen
|
||||
assert settings.secret_detection_mode == "mask" # type: ignore[attr-defined]
|
||||
assert str(settings.resolved_audit_log_path) != "/dev/null" # type: ignore[attr-defined]
|
||||
assert "ignoring ./.env" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_setup_takes_env_file_from_the_variable_and_the_option() -> None:
|
||||
from aegis_gitea_mcp.cli import build_parser
|
||||
|
||||
args = build_parser().parse_args(["setup", "--env-file", "/tmp/x.env"])
|
||||
assert args.env_file == "/tmp/x.env"
|
||||
assert build_parser().parse_args(["setup"]).env_file is None
|
||||
@@ -91,7 +91,9 @@ def test_env_names_are_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None
|
||||
|
||||
|
||||
def test_reads_dotenv_file(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
(tmp_path / ".env").write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
||||
dotenv = tmp_path / ".env"
|
||||
dotenv.write_text("GITEA_URL=https://dotenv.example.com\nREAD_ONLY=true\n")
|
||||
dotenv.chmod(0o600) # an implicit ./.env is only trusted with mode 0600 (SEC-04)
|
||||
reset_settings()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
Reference in new issue
Block a user