""" R7 container/deployment posture - static parse of the deployment artifacts. These assert the hardening is declared in the Dockerfile / docker-compose.yml / supervisord.conf. The RUNTIME acceptance gate (the hardened image builds, Chromium starts without --no-sandbox under userns/seccomp, the read-only rootfs + tmpfs work, redis truly requires auth) needs an actual `docker build` + boot and is tracked as build-gated; it is out of scope for this offline suite. """ import os import re import pytest pytestmark = pytest.mark.posture REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) DOCKER_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) def _read(path): with open(path) as f: return f.read() @pytest.fixture(scope="module") def dockerfile(): return _read(os.path.join(REPO_ROOT, "Dockerfile")) @pytest.fixture(scope="module") def compose(): return _read(os.path.join(REPO_ROOT, "docker-compose.yml")) @pytest.fixture(scope="module") def supervisord(): return _read(os.path.join(DOCKER_DIR, "supervisord.conf")) class TestDockerfile: def test_no_redis_expose(self, dockerfile): # No active EXPOSE 6379 line (commented references are fine). for line in dockerfile.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue assert not re.match(r"EXPOSE\s+.*\b6379\b", stripped), \ "redis port 6379 must not be EXPOSEd" def test_app_dir_root_owned_readonly(self, dockerfile): assert "chown -R root:root ${APP_HOME}" in dockerfile assert "chmod -R a-w ${APP_HOME}" in dockerfile def test_artifact_dir_created_0700(self, dockerfile): assert "/var/lib/crawl4ai/outputs" in dockerfile assert "chmod 700 /var/lib/crawl4ai/outputs" in dockerfile def test_runs_as_non_root(self, dockerfile): assert re.search(r"^USER\s+appuser", dockerfile, re.MULTILINE) class TestSupervisord: def test_redis_requires_password(self, supervisord): assert "--requirepass" in supervisord def test_redis_bound_loopback(self, supervisord): assert "--bind 127.0.0.1" in supervisord def test_gunicorn_bind_is_env_driven(self, supervisord): # entrypoint.sh resolves GUNICORN_BIND (loopback unless a credential). assert "ENV_GUNICORN_BIND" in supervisord def test_gunicorn_request_limits(self, supervisord): assert "--limit-request-line" in supervisord class TestCompose: def test_cap_drop_all(self, compose): assert "cap_drop" in compose and "ALL" in compose def test_no_new_privileges(self, compose): assert "no-new-privileges:true" in compose def test_read_only_rootfs(self, compose): assert re.search(r"read_only:\s*true", compose) def test_no_host_dev_shm_bind(self, compose): assert "/dev/shm:/dev/shm" not in compose assert "shm_size" in compose def test_pids_limit(self, compose): assert "pids_limit" in compose def test_read_only_runtime_tmpfs_are_appuser_owned(self, compose): assert "/var/lib/redis:uid=999,gid=999,mode=0700" in compose assert "/var/lib/crawl4ai/outputs:uid=999,gid=999,mode=0700" in compose assert "/home/appuser/.crawl4ai:uid=999,gid=999,mode=0700" in compose assert "/home/appuser/.gunicorn:uid=999,gid=999,mode=0700" in compose def test_playwright_cache_is_not_shadowed(self, compose): assert "/home/appuser/.cache\n" not in compose assert "/home/appuser/.cache/url_seeder:uid=999,gid=999,mode=0700" in compose class TestEntrypoint: def test_entrypoint_exists_and_resolves_bind(self): src = _read(os.path.join(DOCKER_DIR, "entrypoint.sh")) assert "GUNICORN_BIND" in src and "REDIS_PASSWORD" in src assert "127.0.0.1" in src # loopback default when no credential class TestSandboxOptOut: def test_default_keeps_no_sandbox(self, server_module, monkeypatch): monkeypatch.setattr(server_module, "CHROMIUM_SANDBOX", False) assert "--no-sandbox" in server_module._browser_extra_args() def test_opt_in_drops_no_sandbox(self, server_module, monkeypatch): # CRAWL4AI_CHROMIUM_SANDBOX=true -> run the renderer sandboxed. monkeypatch.setattr(server_module, "CHROMIUM_SANDBOX", True) assert "--no-sandbox" not in server_module._browser_extra_args() # other flags are preserved assert "--disable-dev-shm-usage" in server_module._browser_extra_args()