Files
dzentra_bot/app/tests/unit/infra/test_docker_hardening.py

397 lines
12 KiB
Python

from __future__ import annotations
from pathlib import Path
import re
REPOSITORY_ROOT = Path(__file__).resolve().parents[4]
DOCKERIGNORE = REPOSITORY_ROOT / ".dockerignore"
DOCKERFILE = REPOSITORY_ROOT / "infra" / "docker" / "Dockerfile"
REQUIREMENTS_LOCK = REPOSITORY_ROOT / "app" / "requirements.lock"
POSTGRES_DOCKERFILE = (
REPOSITORY_ROOT / "infra" / "docker" / "postgres" / "Dockerfile"
)
COMPOSE_FILE = (
REPOSITORY_ROOT / "infra" / "compose" / "docker-compose.yml"
)
EXCHANGE_COMPOSE_FILE = (
REPOSITORY_ROOT
/ "infra"
/ "compose"
/ "docker-compose.exchange-auth.yml"
)
POSTGRES_INIT_SCRIPT = (
REPOSITORY_ROOT
/ "infra"
/ "docker"
/ "postgres"
/ "init-application-role.sh"
)
POSTGRES_HEALTHCHECK_SCRIPT = (
REPOSITORY_ROOT
/ "infra"
/ "docker"
/ "postgres"
/ "healthcheck-application-role.sh"
)
PYTHON_IMAGE = (
"python:3.12.13-slim-bookworm@sha256:"
"d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b"
)
POSTGRES_IMAGE = (
"postgres:16.14-alpine@sha256:"
"57c72fd2a128e416c7fcc499958864df5301e940bca0a56f58fddf30ffc07777"
)
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def yaml_mapping_block(
content: str,
*,
key: str,
indentation: int,
) -> str:
lines = content.splitlines()
marker = f"{' ' * indentation}{key}:"
try:
start = lines.index(marker)
except ValueError as error:
raise AssertionError(f"YAML block {key!r} not found") from error
end = len(lines)
for index in range(start + 1, len(lines)):
line = lines[index]
if not line.strip():
continue
current_indentation = len(line) - len(line.lstrip())
if current_indentation <= indentation:
end = index
break
return "\n".join(lines[start:end])
def test_docker_context_is_an_explicit_production_allowlist() -> None:
content = read_text(DOCKERIGNORE)
active_lines = tuple(
line.strip()
for line in content.splitlines()
if line.strip() and not line.lstrip().startswith("#")
)
expected_inclusions = {
"!.dockerignore",
"!infra/",
"!infra/docker/",
"!infra/docker/Dockerfile",
"!infra/docker/postgres/",
"!infra/docker/postgres/Dockerfile",
"!infra/docker/postgres/healthcheck-application-role.sh",
"!infra/docker/postgres/init-application-role.sh",
"!app/",
"!app/requirements.txt",
"!app/requirements.lock",
"!app/src/",
"!app/src/**",
}
assert active_lines[0] == "**"
assert {
line
for line in active_lines
if line.startswith("!")
} == expected_inclusions
assert not any(
line.startswith("!app/.env")
or line.startswith("!app/.venv")
or line.startswith("!app/tests")
for line in active_lines
)
assert "app/src/**/__pycache__/" in active_lines
assert "app/src/**/*.py[cod]" in active_lines
assert {
"app/src/**/.env",
"app/src/**/.env.*",
"app/src/**/*.pem",
"app/src/**/*.key",
"app/src/**/*.p12",
"app/src/**/*.pfx",
"app/src/**/*.log",
"app/src/**/*.dump",
"app/src/**/*.sqlite",
"app/src/**/*.sqlite3",
}.issubset(active_lines)
def test_dockerfile_uses_pinned_minimal_non_root_image() -> None:
content = read_text(DOCKERFILE)
copy_or_add_instructions = tuple(
line.strip()
for line in content.splitlines()
if line.lstrip().startswith(("COPY ", "ADD "))
)
assert content.splitlines()[0] == f"FROM {PYTHON_IMAGE}"
assert copy_or_add_instructions == (
"COPY app/requirements.lock ./requirements.lock",
"COPY --chown=10001:10001 app/src ./src",
)
assert "--require-hashes" in content
assert "--requirement requirements.lock" in content
assert "USER 10001:10001" in content
assert "STOPSIGNAL SIGINT" in content
assert "/var/lib/dzentra/runtime.env" in content
def test_compose_requires_explicit_project_namespace() -> None:
content = read_text(COMPOSE_FILE)
assert content.splitlines()[0] == (
"name: ${DZENTRA_COMPOSE_PROJECT_NAME:"
"?DZENTRA_COMPOSE_PROJECT_NAME must be set}"
)
def test_production_dependencies_have_complete_hash_lock() -> None:
content = read_text(REQUIREMENTS_LOCK)
package_matches = tuple(re.finditer(
r"^([a-z0-9][a-z0-9._-]*)==([^ \\;]+)",
content,
re.MULTILINE,
))
assert len(package_matches) == 25
for index, package_match in enumerate(package_matches):
block_end = (
package_matches[index + 1].start()
if index + 1 < len(package_matches)
else len(content)
)
assert "--hash=sha256:" in content[package_match.start():block_end]
assert "pytest==" not in content
assert "pyright==" not in content
def test_compose_uses_pinned_internal_postgres() -> None:
content = read_text(COMPOSE_FILE)
postgres = yaml_mapping_block(
content,
key="postgres",
indentation=2,
)
postgres_dockerfile = read_text(POSTGRES_DOCKERFILE)
assert postgres_dockerfile.splitlines()[0] == f"FROM {POSTGRES_IMAGE}"
assert postgres_dockerfile.count("COPY --chmod=0555") == 2
assert "ADD " not in postgres_dockerfile
assert "init-application-role.sh" in postgres_dockerfile
assert "healthcheck-application-role.sh" in postgres_dockerfile
assert "dockerfile: infra/docker/postgres/Dockerfile" in postgres
assert "dzentra-postgres:16.14-hardened" in postgres
assert "pull_policy: build" in postgres
assert (
"POSTGRES_PASSWORD_FILE: /run/secrets/postgres_admin_password"
in postgres
)
assert "APP_DB_USER: ${DB_USER:-dzentra_bot}" in postgres
assert "postgres_admin_password" in postgres
assert "db_password" in postgres
assert not re.search(r"^\s+POSTGRES_PASSWORD:\s", postgres, re.MULTILINE)
assert "ports:" not in postgres
assert "container_name:" not in postgres
assert "privileged:" not in postgres
assert "network_mode:" not in postgres
assert yaml_mapping_block(
content,
key="networks",
indentation=0,
).rstrip() == (
"networks:\n"
" database:\n"
" internal: true\n"
" egress:"
)
assert 'restart: "no"' in postgres
assert yaml_mapping_block(
postgres,
key="security_opt",
indentation=4,
) == " security_opt:\n - no-new-privileges:true"
assert yaml_mapping_block(
postgres,
key="cap_drop",
indentation=4,
) == " cap_drop:\n - ALL"
assert yaml_mapping_block(
postgres,
key="cap_add",
indentation=4,
) == (
" cap_add:\n"
" - CHOWN\n"
" - DAC_OVERRIDE\n"
" - FOWNER\n"
" - SETGID\n"
" - SETUID"
)
assert yaml_mapping_block(
postgres,
key="networks",
indentation=4,
) == " networks:\n - database"
assert yaml_mapping_block(
postgres,
key="volumes",
indentation=4,
) == (
" volumes:\n"
" - dzentra_postgres_data:/var/lib/postgresql/data"
)
assert yaml_mapping_block(
postgres,
key="tmpfs",
indentation=4,
) == (
" tmpfs:\n"
" - /tmp:rw,noexec,nosuid,nodev,size=64m\n"
" - /var/run/postgresql:rw,nosuid,nodev,size=16m"
)
assert "read_only: true" in postgres
assert "POSTGRES_STOP_GRACE_PERIOD must be set" in postgres
assert "/usr/local/bin/dzentra-postgres-healthcheck" in postgres
def test_compose_does_not_inject_direct_application_secrets() -> None:
content = read_text(COMPOSE_FILE)
bot = yaml_mapping_block(
content,
key="bot",
indentation=2,
)
assert "env_file:" not in bot
assert "privileged:" not in bot
assert "network_mode:" not in bot
assert "BOT_TOKEN_FILE: /run/secrets/bot_token" in bot
assert "DB_PASSWORD_FILE: /run/secrets/db_password" in bot
assert not re.search(r"^\s+BOT_TOKEN:\s", bot, re.MULTILINE)
assert not re.search(r"^\s+DB_PASSWORD:\s", bot, re.MULTILINE)
assert not re.search(r"^\s+EXCHANGE_API_KEY:\s", bot, re.MULTILINE)
assert not re.search(
r"^\s+EXCHANGE_API_SECRET:\s",
bot,
re.MULTILINE,
)
def test_compose_hardens_bot_and_preserves_runtime_settings() -> None:
content = read_text(COMPOSE_FILE)
bot = yaml_mapping_block(
content,
key="bot",
indentation=2,
)
assert 'user: "10001:10001"' in bot
assert "pull_policy: build" in bot
assert 'restart: "no"' in bot
assert yaml_mapping_block(
bot,
key="security_opt",
indentation=4,
) == " security_opt:\n - no-new-privileges:true"
assert yaml_mapping_block(
bot,
key="cap_drop",
indentation=4,
) == " cap_drop:\n - ALL"
assert " cap_add:" not in bot
assert yaml_mapping_block(
bot,
key="networks",
indentation=4,
) == " networks:\n - database\n - egress"
assert yaml_mapping_block(
bot,
key="volumes",
indentation=4,
) == (
" volumes:\n"
" - dzentra_runtime_config:/var/lib/dzentra"
)
assert yaml_mapping_block(
bot,
key="tmpfs",
indentation=4,
) == " tmpfs:\n - /tmp:rw,noexec,nosuid,nodev,size=64m"
assert "read_only: true" in bot
assert "init: true" in bot
assert "BOT_STOP_GRACE_PERIOD must be set" in bot
assert "DZENTRA_RUNTIME_ENV_FILE: /var/lib/dzentra/runtime.env" in bot
assert "dzentra_runtime_config:/var/lib/dzentra" in bot
def test_compose_declares_file_backed_secret_sources() -> None:
content = read_text(COMPOSE_FILE)
assert "POSTGRES_ADMIN_PASSWORD_SECRET_FILE must be set" in content
assert "BOT_TOKEN_SECRET_FILE must be set" in content
assert "DB_PASSWORD_SECRET_FILE must be set" in content
assert "file: ${BOT_TOKEN_SECRET_FILE:" in content
assert "file: ${DB_PASSWORD_SECRET_FILE:" in content
def test_exchange_credentials_have_explicit_opt_in_override() -> None:
content = read_text(EXCHANGE_COMPOSE_FILE)
assert "EXCHANGE_API_KEY_FILE: /run/secrets/exchange_api_key" in content
assert (
"EXCHANGE_API_SECRET_FILE: /run/secrets/exchange_api_secret"
in content
)
assert "EXCHANGE_API_KEY_SOURCE_FILE must be set" in content
assert "EXCHANGE_API_SECRET_SOURCE_FILE must be set" in content
assert not re.search(r"^\s+EXCHANGE_API_KEY:\s", content, re.MULTILINE)
assert not re.search(
r"^\s+EXCHANGE_API_SECRET:\s",
content,
re.MULTILINE,
)
def test_postgres_bootstrap_separates_admin_and_application_roles() -> None:
content = read_text(POSTGRES_INIT_SCRIPT)
assert 'if [ "$APP_DB_USER" = "$POSTGRES_USER" ]' in content
assert "Пароли PostgreSQL admin и application roles должны различаться" in content
assert "postgres_admin_password=$POSTGRES_PASSWORD" in content
assert "read_secret_file /run/secrets/postgres_admin_password" not in content
assert "--single-transaction" in content
assert "--no-psqlrc" in content
assert "\\getenv app_password DZENTRA_APP_DB_PASSWORD" in content
assert "NOSUPERUSER NOCREATEDB" in content
assert "NOCREATEROLE INHERIT NOREPLICATION NOBYPASSRLS" in content
assert "ALTER DATABASE %I OWNER TO %I" in content
assert "GRANT USAGE, CREATE ON SCHEMA public TO %I" in content
assert "--set app_password" not in content
def test_postgres_healthcheck_rejects_privileged_application_role() -> None:
content = read_text(POSTGRES_HEALTHCHECK_SCRIPT)
assert "NOT rolsuper" in content
assert "NOT rolcreatedb" in content
assert "NOT rolcreaterole" in content
assert "NOT rolreplication" in content
assert "NOT rolbypassrls" in content
assert "pg_catalog.pg_auth_members" in content
assert "member_role.rolname = :'app_user'" in content
assert "pg_catalog.pg_get_userbyid" in content
assert "test \"$role_status\" = \"1\"" in content