"""Section 7.2 — Secrets management tests. Covers: - No secrets baked into Docker image (Dockerfile ARG/ENV/COPY audit) - Runtime injection: deploy.sh uses --env-file, not build-time ARG - Rotation runbook: exists, documents all four secrets, rotation commands - SSM secrets script: exists, valid shell, no hardcoded secrets - setup-ec2.sh generates fresh secrets (openssl rand / Fernet), not defaults - .env on disk is mode 600 convention (documented in secrets.sh) - Settings weak-password guard (covered in 7.1; re-verified here for secrets) """ from __future__ import annotations import ast import re import subprocess from pathlib import Path import pytest _ROOT = Path(__file__).resolve().parents[1] _DOCKERFILE = _ROOT / "Dockerfile" _DEPLOY_SH = _ROOT / "deploy" / "deploy.sh" _SECRETS_SH = _ROOT / "deploy" / "secrets.sh" _SETUP_EC2 = _ROOT / "deploy" / "setup-ec2.sh" _SETUP_STG = _ROOT / "deploy" / "setup-ec2-staging.sh" _ROTATION_RUNBOOK = _ROOT / "docs" / "secret-rotation-runbook.md" _COMPOSE = _ROOT / "docker-compose.yml" # ═══════════════════════════════════════════════════════════════════════════════ # Docker image: no secrets baked into layers # ═══════════════════════════════════════════════════════════════════════════════ class TestDockerImageLayers: _src = _DOCKERFILE.read_text() # Patterns that indicate a secret value is burned into the image. _SECRET_KEYWORDS = re.compile( r'\b(PASSWORD|SECRET|TOKEN|CREDENTIAL|API_KEY|PRIVATE_KEY)\b', re.IGNORECASE, ) def test_no_secret_arg_in_dockerfile(self) -> None: """No ARG instruction should carry a secret name.""" for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue if stripped.upper().startswith("ARG "): arg_name = stripped[4:].split("=")[0].strip().upper() assert not self._SECRET_KEYWORDS.search(arg_name), ( f"Dockerfile has ARG {arg_name!r} — secrets must not be build args " "(they appear in `docker history` and image metadata)" ) def test_no_secret_env_in_dockerfile(self) -> None: """No ENV instruction should set a secret value.""" for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue if stripped.upper().startswith("ENV "): # ENV KEY=value or ENV KEY value env_decl = stripped[4:].strip() key = re.split(r'[=\s]', env_decl)[0].upper() assert not self._SECRET_KEYWORDS.search(key), ( f"Dockerfile has ENV {key!r} — secrets must not be baked into the image" ) def test_no_env_file_copied_into_image(self) -> None: """Dockerfile must not COPY .env files into the image.""" for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue if stripped.upper().startswith("COPY") or stripped.upper().startswith("ADD"): # Check if the source path looks like a .env file parts = stripped.split() if len(parts) >= 2: source = parts[1] assert not re.match(r'\.env(\.|$)', source), ( f"Dockerfile copies {source!r} — .env files must never be " "baked into image layers" ) def test_only_safe_env_vars_in_dockerfile(self) -> None: """The only ENV vars in the Dockerfile should be Python runtime settings.""" _SAFE_VARS = {"PYTHONPATH", "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED"} for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue if stripped.upper().startswith("ENV "): env_decl = stripped[4:].strip() key = re.split(r'[=\s]', env_decl)[0].upper() assert key in _SAFE_VARS, ( f"Dockerfile sets ENV {key!r} which is not in the approved safe list " f"{_SAFE_VARS}. Runtime config must come from --env-file." ) def test_multi_stage_build_no_secret_in_builder(self) -> None: """Builder stage must not receive secrets (they'd be in intermediate layers).""" in_builder = False for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue if re.match(r'^FROM\s+\S+\s+AS\s+builder', stripped, re.IGNORECASE): in_builder = True elif re.match(r'^FROM\s+', stripped, re.IGNORECASE): in_builder = False if in_builder and stripped.upper().startswith("ARG "): arg_name = stripped[4:].split("=")[0].strip().upper() if self._SECRET_KEYWORDS.search(arg_name): pytest.fail( f"Builder stage has ARG {arg_name!r} — " "secrets in builder layers are never truly deleted" ) # ═══════════════════════════════════════════════════════════════════════════════ # Runtime injection: deploy.sh uses --env-file, not baked-in secrets # ═══════════════════════════════════════════════════════════════════════════════ class TestRuntimeInjection: _src = _DEPLOY_SH.read_text() def test_deploy_uses_env_file_flag(self) -> None: """deploy.sh must pass --env-file to docker run (runtime injection).""" assert "--env-file" in self._src, ( "deploy.sh does not use --env-file — secrets may be baked in or missing" ) def test_deploy_does_not_hardcode_passwords(self) -> None: """deploy.sh must not contain hardcoded secret values.""" for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue # Reject lines like DB_PASSWORD="literal_value" where value is not # a variable reference or command substitution m = re.search(r'(DB_PASSWORD|SECRET_KEY|RUNNER_TOKEN|API_KEY)\s*=\s*["\']?([^${\s"\']+)["\']?', stripped) if m: value = m.group(2) # Allow variable references like ${DB_PASSWORD} or command subs if not value.startswith("$") and not value.startswith("`"): pytest.fail( f"deploy.sh appears to hardcode {m.group(1)}={value!r} — " "secrets must come from .env, not be inline in the script" ) def test_deploy_reads_env_from_app_dir(self) -> None: """deploy.sh must read .env from the app directory, not from the repo.""" assert "/opt/musehub/.env" in self._src or "APP_DIR" in self._src, ( "deploy.sh does not reference a server-side .env path" ) def test_docker_compose_does_not_inline_secrets(self) -> None: """docker-compose.yml must not contain literal (non-variable) secret values. Variable references like ${DB_PASSWORD} or ${DB_PASSWORD:-default} are fine — they expand at runtime from the .env file. Literal strings are not. """ src = _COMPOSE.read_text() _SECRET_KEY = re.compile( r'\b(DB_PASSWORD|SECRET_KEY|RUNNER_TOKEN|BLOB_STORAGE_SECRET_ACCESS_KEY)\b', re.IGNORECASE, ) for line in src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue if not _SECRET_KEY.search(stripped): continue # Extract the value portion (after the first = or :) value_part = re.split(r'[=:]', stripped, maxsplit=1)[-1].strip().strip('"\'') # Allow: variable references ($VAR, ${VAR}, ${VAR:-default}) # Reject: any non-empty literal that doesn't contain ${ or $ if value_part and "$" not in value_part: pytest.fail( f"docker-compose.yml has literal secret value on line: {line!r}\n" "Use ${{VAR}} references that expand from the .env file at runtime." ) # ═══════════════════════════════════════════════════════════════════════════════ # SSM secrets script # ═══════════════════════════════════════════════════════════════════════════════ class TestSecretsScript: _src = _SECRETS_SH.read_text() def test_secrets_sh_exists(self) -> None: assert _SECRETS_SH.exists(), "deploy/secrets.sh must exist" def test_secrets_sh_is_valid_shell(self) -> None: """secrets.sh must pass bash syntax check.""" result = subprocess.run( ["bash", "-n", str(_SECRETS_SH)], capture_output=True, text=True, ) assert result.returncode == 0, ( f"deploy/secrets.sh has bash syntax errors:\n{result.stderr}" ) def test_secrets_sh_reads_from_ssm(self) -> None: """secrets.sh must call aws ssm get-parameter (not inline secrets).""" assert "ssm get-parameter" in self._src or "ssm get-parameters-by-path" in self._src def test_secrets_sh_uses_with_decryption(self) -> None: """SSM fetch must use --with-decryption (SecureString parameters).""" assert "--with-decryption" in self._src def test_secrets_sh_does_not_hardcode_secrets(self) -> None: """secrets.sh must not contain literal secret values.""" for line in self._src.splitlines(): stripped = line.strip() if stripped.startswith("#"): continue # Reject if a secret-sounding variable is assigned a non-variable literal m = re.search( r'(DB_PASSWORD|WEBHOOK_SECRET_KEY|RUNNER_TOKEN|BLOB_STORAGE_SECRET)\s*=\s*["\']([^$][^"\']{7,})["\']', stripped, ) if m: pytest.fail( f"secrets.sh hardcodes {m.group(1)!r} — must read from SSM" ) def test_secrets_sh_validates_password_strength(self) -> None: """secrets.sh must sanity-check the fetched DB_PASSWORD.""" assert "weak" in self._src.lower() or "WEAK" in self._src, ( "secrets.sh does not validate DB_PASSWORD strength — " "a weak value from SSM would pass silently" ) def test_secrets_sh_writes_mode_600_env(self) -> None: """secrets.sh must write .env with restricted permissions (umask 177 or chmod).""" assert "umask 177" in self._src or "chmod 600" in self._src, ( "secrets.sh does not set restrictive permissions on .env — " "other users on the host could read it" ) def test_secrets_sh_fails_on_error(self) -> None: """secrets.sh must use set -euo pipefail.""" assert "set -euo pipefail" in self._src or "set -e" in self._src # ═══════════════════════════════════════════════════════════════════════════════ # setup-ec2.sh: generates fresh secrets, not defaults # ═══════════════════════════════════════════════════════════════════════════════ class TestSetupEc2GeneratesSecrets: def test_setup_ec2_generates_db_password(self) -> None: """setup-ec2.sh must generate a fresh DB password, not use a default.""" src = _SETUP_EC2.read_text() # Must use openssl rand or similar assert "openssl rand" in src, ( "setup-ec2.sh does not generate a fresh DB_PASSWORD — " "operators would use the weak default from .env.example" ) def test_setup_ec2_generates_webhook_key(self) -> None: """setup-ec2.sh must generate a Fernet key for WEBHOOK_SECRET_KEY.""" src = _SETUP_EC2.read_text() assert "Fernet" in src or "fernet" in src, ( "setup-ec2.sh does not generate a WEBHOOK_SECRET_KEY — " "webhook encryption would be disabled on fresh installs" ) def test_setup_ec2_does_not_commit_weak_defaults(self) -> None: """setup-ec2.sh must not write known-weak passwords to .env.""" src = _SETUP_EC2.read_text() _WEAK = ["DB_PASSWORD=musehub", "DB_PASSWORD=changeme", "DB_PASSWORD=password"] for weak in _WEAK: assert weak not in src, ( f"setup-ec2.sh writes weak password: {weak!r}" ) def test_setup_staging_generates_secrets(self) -> None: """setup-ec2-staging.sh must also generate secrets for staging.""" src = _SETUP_STG.read_text() # Staging must either generate secrets or reference secrets.sh has_generation = "openssl rand" in src or "secrets.sh" in src or "Fernet" in src assert has_generation, ( "setup-ec2-staging.sh does not generate secrets — " "staging would start with default/empty credentials" ) # ═══════════════════════════════════════════════════════════════════════════════ # Rotation runbook # ═══════════════════════════════════════════════════════════════════════════════ class TestRotationRunbook: _src = _ROTATION_RUNBOOK.read_text() def test_runbook_exists(self) -> None: assert _ROTATION_RUNBOOK.exists(), "docs/secret-rotation-runbook.md must exist" def test_runbook_covers_db_password(self) -> None: assert "DB_PASSWORD" in self._src def test_runbook_covers_webhook_key(self) -> None: assert "WEBHOOK_SECRET_KEY" in self._src def test_runbook_covers_runner_token(self) -> None: assert "RUNNER_TOKEN" in self._src def test_runbook_documents_db_rotation_schedule(self) -> None: """DB password must have a rotation schedule (180 days).""" assert "180" in self._src, ( "Runbook does not document 180-day DB password rotation schedule" ) def test_runbook_documents_compromise_response(self) -> None: """Runbook must cover what to do on compromise.""" assert re.search(r'comprom', self._src, re.IGNORECASE), ( "Runbook does not cover compromise response" ) def test_runbook_documents_ssm_commands(self) -> None: """Runbook must show actual ssm put-parameter commands.""" assert "ssm put-parameter" in self._src def test_runbook_documents_docker_image_audit(self) -> None: """Runbook must document how to audit Docker image layers.""" assert "docker history" in self._src def test_runbook_documents_cloudtrail_audit(self) -> None: """Runbook must reference CloudTrail for SSM access auditing.""" assert "CloudTrail" in self._src or "cloudtrail" in self._src.lower() # ═══════════════════════════════════════════════════════════════════════════════ # Settings: production guards (ensure 7.1 guards still in place for 7.2 context) # ═══════════════════════════════════════════════════════════════════════════════ class TestProductionGuardsForSecrets: def test_weak_passwords_known_to_startup_guard(self) -> None: """The set of weak passwords checked at startup must include common defaults.""" src = _ROOT.joinpath("musehub", "main.py").read_text() for weak in ["musehub", "changeme", "password"]: assert weak in src, ( f"Startup guard does not block {weak!r} as a weak DB_PASSWORD" ) def test_fernet_key_format_documented_in_runbook(self) -> None: """Runbook must show Fernet.generate_key() so operators use the right format.""" assert "Fernet.generate_key" in _ROTATION_RUNBOOK.read_text()