- app runs as uid 1000; a one-shot data-permissions service chowns ./data to it and closes it to others (works on an older root-owned install with no host sudo); files are created 0600 (umask 077) - python:3.12-slim and caddy:2-alpine pinned by digest; dependencies installed from requirements.lock with --require-hashes - the Fernet key is XPRV_ENCRYPTION_KEY_FILE (./secrets/xprv_encryption.key, a compose secret), out of every environment variable and of a ./data backup - setup.md no longer presents a second way to run the server (B-72) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
71 lines
2.6 KiB
Python
71 lines
2.6 KiB
Python
"""B-91 (SEC-51, SEC-52, SEC-53): non-root, pinned images and dependencies, the key in a file."""
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.config import ConfigError, Settings, validate_runtime_secrets
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _read(name):
|
|
return (ROOT / name).read_text()
|
|
|
|
|
|
def test_the_dockerfile_pins_its_base_image_and_runs_as_a_non_root_user():
|
|
dockerfile = _read("Dockerfile")
|
|
assert re.search(r"^FROM python:3\.12-slim@sha256:[0-9a-f]{64}$", dockerfile, re.M)
|
|
assert re.search(r"^USER 1000:1000$", dockerfile, re.M)
|
|
assert "--require-hashes" in dockerfile and "umask 077" in dockerfile
|
|
|
|
|
|
def test_every_locked_requirement_carries_a_hash():
|
|
lock = _read("requirements.lock")
|
|
pinned = re.findall(r"^([A-Za-z0-9_.\-]+==\S+) \\$", lock, re.M)
|
|
assert len(pinned) > 20
|
|
blocks = re.split(r"\n(?=[A-Za-z0-9_.\-]+==)", lock)[1:]
|
|
assert all("--hash=sha256:" in block for block in blocks)
|
|
|
|
|
|
def test_compose_pins_caddy_and_keeps_the_key_out_of_the_environment():
|
|
compose = _read("docker-compose.yml")
|
|
assert re.search(r"image: caddy:2-alpine@sha256:[0-9a-f]{64}", compose)
|
|
assert "XPRV_ENCRYPTION_KEY_FILE=/run/secrets/xprv_encryption_key" in compose
|
|
assert "file: ./secrets/xprv_encryption.key" in compose
|
|
assert not re.search(r"XPRV_ENCRYPTION_KEY=", compose)
|
|
assert "data-permissions" in compose and "service_completed_successfully" in compose
|
|
|
|
|
|
def test_the_example_env_points_at_a_file_not_a_key():
|
|
example = _read(".env.example")
|
|
assert re.search(r"^XPRV_ENCRYPTION_KEY_FILE=", example, re.M)
|
|
assert not re.search(r"^XPRV_ENCRYPTION_KEY=", example, re.M)
|
|
|
|
|
|
@pytest.mark.parametrize("ignore", [".gitignore", ".dockerignore"])
|
|
def test_the_secrets_folder_is_kept_out_of_git_and_the_image(ignore):
|
|
assert "secrets/" in _read(ignore).split()
|
|
|
|
|
|
def _settings(**overrides):
|
|
return Settings(_env_file=None, jwt_secret="x" * 32, **overrides)
|
|
|
|
|
|
def test_the_key_is_read_from_its_file(tmp_path):
|
|
key_file = tmp_path / "k"
|
|
key_file.write_text("a-fernet-key\n")
|
|
config = _settings(xprv_encryption_key_file=str(key_file))
|
|
assert config.xprv_encryption_key == "a-fernet-key"
|
|
validate_runtime_secrets(config)
|
|
|
|
|
|
@pytest.mark.parametrize("content", [None, " \n"])
|
|
def test_a_missing_or_empty_key_file_stops_the_server_naming_the_file(tmp_path, content):
|
|
key_file = tmp_path / "k"
|
|
if content is not None:
|
|
key_file.write_text(content)
|
|
with pytest.raises(ConfigError, match="XPRV_ENCRYPTION_KEY_FILE .*is missing or empty"):
|
|
validate_runtime_secrets(_settings(xprv_encryption_key_file=str(key_file)))
|