test_secrets_management.py
file-level
1
files
1
commits
0
hotspots
0
π§ dead
0
π₯ blast risk
| 1 | """Section 7.2 β Secrets management tests. |
| 2 | |
| 3 | Covers: |
| 4 | - No secrets baked into Docker image (Dockerfile ARG/ENV/COPY audit) |
| 5 | - Runtime injection: deploy.sh uses --env-file, not build-time ARG |
| 6 | - Rotation runbook: exists, documents all four secrets, rotation commands |
| 7 | - SSM secrets script: exists, valid shell, no hardcoded secrets |
| 8 | - setup-ec2.sh generates fresh secrets (openssl rand / Fernet), not defaults |
| 9 | - .env on disk is mode 600 convention (documented in secrets.sh) |
| 10 | - Settings weak-password guard (covered in 7.1; re-verified here for secrets) |
| 11 | """ |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | import ast |
| 15 | import re |
| 16 | import subprocess |
| 17 | from pathlib import Path |
| 18 | |
| 19 | import pytest |
| 20 | |
| 21 | _ROOT = Path(__file__).resolve().parents[1] |
| 22 | _DOCKERFILE = _ROOT / "Dockerfile" |
| 23 | _DEPLOY_SH = _ROOT / "deploy" / "deploy.sh" |
| 24 | _SECRETS_SH = _ROOT / "deploy" / "secrets.sh" |
| 25 | _SETUP_EC2 = _ROOT / "deploy" / "setup-ec2.sh" |
| 26 | _SETUP_STG = _ROOT / "deploy" / "setup-ec2-staging.sh" |
| 27 | _ROTATION_RUNBOOK = _ROOT / "docs" / "secret-rotation-runbook.md" |
| 28 | _COMPOSE = _ROOT / "docker-compose.yml" |
| 29 | |
| 30 | |
| 31 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 32 | # Docker image: no secrets baked into layers |
| 33 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 34 | |
| 35 | class TestDockerImageLayers: |
| 36 | _src = _DOCKERFILE.read_text() |
| 37 | |
| 38 | # Patterns that indicate a secret value is burned into the image. |
| 39 | _SECRET_KEYWORDS = re.compile( |
| 40 | r'\b(PASSWORD|SECRET|TOKEN|CREDENTIAL|API_KEY|PRIVATE_KEY)\b', |
| 41 | re.IGNORECASE, |
| 42 | ) |
| 43 | |
| 44 | def test_no_secret_arg_in_dockerfile(self) -> None: |
| 45 | """No ARG instruction should carry a secret name.""" |
| 46 | for line in self._src.splitlines(): |
| 47 | stripped = line.strip() |
| 48 | if stripped.startswith("#"): |
| 49 | continue |
| 50 | if stripped.upper().startswith("ARG "): |
| 51 | arg_name = stripped[4:].split("=")[0].strip().upper() |
| 52 | assert not self._SECRET_KEYWORDS.search(arg_name), ( |
| 53 | f"Dockerfile has ARG {arg_name!r} β secrets must not be build args " |
| 54 | "(they appear in `docker history` and image metadata)" |
| 55 | ) |
| 56 | |
| 57 | def test_no_secret_env_in_dockerfile(self) -> None: |
| 58 | """No ENV instruction should set a secret value.""" |
| 59 | for line in self._src.splitlines(): |
| 60 | stripped = line.strip() |
| 61 | if stripped.startswith("#"): |
| 62 | continue |
| 63 | if stripped.upper().startswith("ENV "): |
| 64 | # ENV KEY=value or ENV KEY value |
| 65 | env_decl = stripped[4:].strip() |
| 66 | key = re.split(r'[=\s]', env_decl)[0].upper() |
| 67 | assert not self._SECRET_KEYWORDS.search(key), ( |
| 68 | f"Dockerfile has ENV {key!r} β secrets must not be baked into the image" |
| 69 | ) |
| 70 | |
| 71 | def test_no_env_file_copied_into_image(self) -> None: |
| 72 | """Dockerfile must not COPY .env files into the image.""" |
| 73 | for line in self._src.splitlines(): |
| 74 | stripped = line.strip() |
| 75 | if stripped.startswith("#"): |
| 76 | continue |
| 77 | if stripped.upper().startswith("COPY") or stripped.upper().startswith("ADD"): |
| 78 | # Check if the source path looks like a .env file |
| 79 | parts = stripped.split() |
| 80 | if len(parts) >= 2: |
| 81 | source = parts[1] |
| 82 | assert not re.match(r'\.env(\.|$)', source), ( |
| 83 | f"Dockerfile copies {source!r} β .env files must never be " |
| 84 | "baked into image layers" |
| 85 | ) |
| 86 | |
| 87 | def test_only_safe_env_vars_in_dockerfile(self) -> None: |
| 88 | """The only ENV vars in the Dockerfile should be Python runtime settings.""" |
| 89 | _SAFE_VARS = {"PYTHONPATH", "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED"} |
| 90 | for line in self._src.splitlines(): |
| 91 | stripped = line.strip() |
| 92 | if stripped.startswith("#"): |
| 93 | continue |
| 94 | if stripped.upper().startswith("ENV "): |
| 95 | env_decl = stripped[4:].strip() |
| 96 | key = re.split(r'[=\s]', env_decl)[0].upper() |
| 97 | assert key in _SAFE_VARS, ( |
| 98 | f"Dockerfile sets ENV {key!r} which is not in the approved safe list " |
| 99 | f"{_SAFE_VARS}. Runtime config must come from --env-file." |
| 100 | ) |
| 101 | |
| 102 | def test_multi_stage_build_no_secret_in_builder(self) -> None: |
| 103 | """Builder stage must not receive secrets (they'd be in intermediate layers).""" |
| 104 | in_builder = False |
| 105 | for line in self._src.splitlines(): |
| 106 | stripped = line.strip() |
| 107 | if stripped.startswith("#"): |
| 108 | continue |
| 109 | if re.match(r'^FROM\s+\S+\s+AS\s+builder', stripped, re.IGNORECASE): |
| 110 | in_builder = True |
| 111 | elif re.match(r'^FROM\s+', stripped, re.IGNORECASE): |
| 112 | in_builder = False |
| 113 | if in_builder and stripped.upper().startswith("ARG "): |
| 114 | arg_name = stripped[4:].split("=")[0].strip().upper() |
| 115 | if self._SECRET_KEYWORDS.search(arg_name): |
| 116 | pytest.fail( |
| 117 | f"Builder stage has ARG {arg_name!r} β " |
| 118 | "secrets in builder layers are never truly deleted" |
| 119 | ) |
| 120 | |
| 121 | |
| 122 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 123 | # Runtime injection: deploy.sh uses --env-file, not baked-in secrets |
| 124 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 125 | |
| 126 | class TestRuntimeInjection: |
| 127 | _src = _DEPLOY_SH.read_text() |
| 128 | |
| 129 | def test_deploy_uses_env_file_flag(self) -> None: |
| 130 | """deploy.sh must pass --env-file to docker run (runtime injection).""" |
| 131 | assert "--env-file" in self._src, ( |
| 132 | "deploy.sh does not use --env-file β secrets may be baked in or missing" |
| 133 | ) |
| 134 | |
| 135 | def test_deploy_does_not_hardcode_passwords(self) -> None: |
| 136 | """deploy.sh must not contain hardcoded secret values.""" |
| 137 | for line in self._src.splitlines(): |
| 138 | stripped = line.strip() |
| 139 | if stripped.startswith("#"): |
| 140 | continue |
| 141 | # Reject lines like DB_PASSWORD="literal_value" where value is not |
| 142 | # a variable reference or command substitution |
| 143 | m = re.search(r'(DB_PASSWORD|SECRET_KEY|RUNNER_TOKEN|API_KEY)\s*=\s*["\']?([^${\s"\']+)["\']?', stripped) |
| 144 | if m: |
| 145 | value = m.group(2) |
| 146 | # Allow variable references like ${DB_PASSWORD} or command subs |
| 147 | if not value.startswith("$") and not value.startswith("`"): |
| 148 | pytest.fail( |
| 149 | f"deploy.sh appears to hardcode {m.group(1)}={value!r} β " |
| 150 | "secrets must come from .env, not be inline in the script" |
| 151 | ) |
| 152 | |
| 153 | def test_deploy_reads_env_from_app_dir(self) -> None: |
| 154 | """deploy.sh must read .env from the app directory, not from the repo.""" |
| 155 | assert "/opt/musehub/.env" in self._src or "APP_DIR" in self._src, ( |
| 156 | "deploy.sh does not reference a server-side .env path" |
| 157 | ) |
| 158 | |
| 159 | def test_docker_compose_does_not_inline_secrets(self) -> None: |
| 160 | """docker-compose.yml must not contain literal (non-variable) secret values. |
| 161 | |
| 162 | Variable references like ${DB_PASSWORD} or ${DB_PASSWORD:-default} are fine β |
| 163 | they expand at runtime from the .env file. Literal strings are not. |
| 164 | """ |
| 165 | src = _COMPOSE.read_text() |
| 166 | _SECRET_KEY = re.compile( |
| 167 | r'\b(DB_PASSWORD|SECRET_KEY|RUNNER_TOKEN|BLOB_STORAGE_SECRET_ACCESS_KEY)\b', |
| 168 | re.IGNORECASE, |
| 169 | ) |
| 170 | for line in src.splitlines(): |
| 171 | stripped = line.strip() |
| 172 | if stripped.startswith("#"): |
| 173 | continue |
| 174 | if not _SECRET_KEY.search(stripped): |
| 175 | continue |
| 176 | # Extract the value portion (after the first = or :) |
| 177 | value_part = re.split(r'[=:]', stripped, maxsplit=1)[-1].strip().strip('"\'') |
| 178 | # Allow: variable references ($VAR, ${VAR}, ${VAR:-default}) |
| 179 | # Reject: any non-empty literal that doesn't contain ${ or $ |
| 180 | if value_part and "$" not in value_part: |
| 181 | pytest.fail( |
| 182 | f"docker-compose.yml has literal secret value on line: {line!r}\n" |
| 183 | "Use ${{VAR}} references that expand from the .env file at runtime." |
| 184 | ) |
| 185 | |
| 186 | |
| 187 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 188 | # SSM secrets script |
| 189 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 190 | |
| 191 | class TestSecretsScript: |
| 192 | _src = _SECRETS_SH.read_text() |
| 193 | |
| 194 | def test_secrets_sh_exists(self) -> None: |
| 195 | assert _SECRETS_SH.exists(), "deploy/secrets.sh must exist" |
| 196 | |
| 197 | def test_secrets_sh_is_valid_shell(self) -> None: |
| 198 | """secrets.sh must pass bash syntax check.""" |
| 199 | result = subprocess.run( |
| 200 | ["bash", "-n", str(_SECRETS_SH)], |
| 201 | capture_output=True, text=True, |
| 202 | ) |
| 203 | assert result.returncode == 0, ( |
| 204 | f"deploy/secrets.sh has bash syntax errors:\n{result.stderr}" |
| 205 | ) |
| 206 | |
| 207 | def test_secrets_sh_reads_from_ssm(self) -> None: |
| 208 | """secrets.sh must call aws ssm get-parameter (not inline secrets).""" |
| 209 | assert "ssm get-parameter" in self._src or "ssm get-parameters-by-path" in self._src |
| 210 | |
| 211 | def test_secrets_sh_uses_with_decryption(self) -> None: |
| 212 | """SSM fetch must use --with-decryption (SecureString parameters).""" |
| 213 | assert "--with-decryption" in self._src |
| 214 | |
| 215 | def test_secrets_sh_does_not_hardcode_secrets(self) -> None: |
| 216 | """secrets.sh must not contain literal secret values.""" |
| 217 | for line in self._src.splitlines(): |
| 218 | stripped = line.strip() |
| 219 | if stripped.startswith("#"): |
| 220 | continue |
| 221 | # Reject if a secret-sounding variable is assigned a non-variable literal |
| 222 | m = re.search( |
| 223 | r'(DB_PASSWORD|WEBHOOK_SECRET_KEY|RUNNER_TOKEN|BLOB_STORAGE_SECRET)\s*=\s*["\']([^$][^"\']{7,})["\']', |
| 224 | stripped, |
| 225 | ) |
| 226 | if m: |
| 227 | pytest.fail( |
| 228 | f"secrets.sh hardcodes {m.group(1)!r} β must read from SSM" |
| 229 | ) |
| 230 | |
| 231 | def test_secrets_sh_validates_password_strength(self) -> None: |
| 232 | """secrets.sh must sanity-check the fetched DB_PASSWORD.""" |
| 233 | assert "weak" in self._src.lower() or "WEAK" in self._src, ( |
| 234 | "secrets.sh does not validate DB_PASSWORD strength β " |
| 235 | "a weak value from SSM would pass silently" |
| 236 | ) |
| 237 | |
| 238 | def test_secrets_sh_writes_mode_600_env(self) -> None: |
| 239 | """secrets.sh must write .env with restricted permissions (umask 177 or chmod).""" |
| 240 | assert "umask 177" in self._src or "chmod 600" in self._src, ( |
| 241 | "secrets.sh does not set restrictive permissions on .env β " |
| 242 | "other users on the host could read it" |
| 243 | ) |
| 244 | |
| 245 | def test_secrets_sh_fails_on_error(self) -> None: |
| 246 | """secrets.sh must use set -euo pipefail.""" |
| 247 | assert "set -euo pipefail" in self._src or "set -e" in self._src |
| 248 | |
| 249 | |
| 250 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 251 | # setup-ec2.sh: generates fresh secrets, not defaults |
| 252 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 253 | |
| 254 | class TestSetupEc2GeneratesSecrets: |
| 255 | def test_setup_ec2_generates_db_password(self) -> None: |
| 256 | """setup-ec2.sh must generate a fresh DB password, not use a default.""" |
| 257 | src = _SETUP_EC2.read_text() |
| 258 | # Must use openssl rand or similar |
| 259 | assert "openssl rand" in src, ( |
| 260 | "setup-ec2.sh does not generate a fresh DB_PASSWORD β " |
| 261 | "operators would use the weak default from .env.example" |
| 262 | ) |
| 263 | |
| 264 | def test_setup_ec2_generates_webhook_key(self) -> None: |
| 265 | """setup-ec2.sh must generate a Fernet key for WEBHOOK_SECRET_KEY.""" |
| 266 | src = _SETUP_EC2.read_text() |
| 267 | assert "Fernet" in src or "fernet" in src, ( |
| 268 | "setup-ec2.sh does not generate a WEBHOOK_SECRET_KEY β " |
| 269 | "webhook encryption would be disabled on fresh installs" |
| 270 | ) |
| 271 | |
| 272 | def test_setup_ec2_does_not_commit_weak_defaults(self) -> None: |
| 273 | """setup-ec2.sh must not write known-weak passwords to .env.""" |
| 274 | src = _SETUP_EC2.read_text() |
| 275 | _WEAK = ["DB_PASSWORD=musehub", "DB_PASSWORD=changeme", "DB_PASSWORD=password"] |
| 276 | for weak in _WEAK: |
| 277 | assert weak not in src, ( |
| 278 | f"setup-ec2.sh writes weak password: {weak!r}" |
| 279 | ) |
| 280 | |
| 281 | def test_setup_staging_generates_secrets(self) -> None: |
| 282 | """setup-ec2-staging.sh must also generate secrets for staging.""" |
| 283 | src = _SETUP_STG.read_text() |
| 284 | # Staging must either generate secrets or reference secrets.sh |
| 285 | has_generation = "openssl rand" in src or "secrets.sh" in src or "Fernet" in src |
| 286 | assert has_generation, ( |
| 287 | "setup-ec2-staging.sh does not generate secrets β " |
| 288 | "staging would start with default/empty credentials" |
| 289 | ) |
| 290 | |
| 291 | |
| 292 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 293 | # Rotation runbook |
| 294 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 295 | |
| 296 | class TestRotationRunbook: |
| 297 | _src = _ROTATION_RUNBOOK.read_text() |
| 298 | |
| 299 | def test_runbook_exists(self) -> None: |
| 300 | assert _ROTATION_RUNBOOK.exists(), "docs/secret-rotation-runbook.md must exist" |
| 301 | |
| 302 | def test_runbook_covers_db_password(self) -> None: |
| 303 | assert "DB_PASSWORD" in self._src |
| 304 | |
| 305 | def test_runbook_covers_webhook_key(self) -> None: |
| 306 | assert "WEBHOOK_SECRET_KEY" in self._src |
| 307 | |
| 308 | def test_runbook_covers_runner_token(self) -> None: |
| 309 | assert "RUNNER_TOKEN" in self._src |
| 310 | |
| 311 | def test_runbook_documents_db_rotation_schedule(self) -> None: |
| 312 | """DB password must have a rotation schedule (180 days).""" |
| 313 | assert "180" in self._src, ( |
| 314 | "Runbook does not document 180-day DB password rotation schedule" |
| 315 | ) |
| 316 | |
| 317 | def test_runbook_documents_compromise_response(self) -> None: |
| 318 | """Runbook must cover what to do on compromise.""" |
| 319 | assert re.search(r'comprom', self._src, re.IGNORECASE), ( |
| 320 | "Runbook does not cover compromise response" |
| 321 | ) |
| 322 | |
| 323 | def test_runbook_documents_ssm_commands(self) -> None: |
| 324 | """Runbook must show actual ssm put-parameter commands.""" |
| 325 | assert "ssm put-parameter" in self._src |
| 326 | |
| 327 | def test_runbook_documents_docker_image_audit(self) -> None: |
| 328 | """Runbook must document how to audit Docker image layers.""" |
| 329 | assert "docker history" in self._src |
| 330 | |
| 331 | def test_runbook_documents_cloudtrail_audit(self) -> None: |
| 332 | """Runbook must reference CloudTrail for SSM access auditing.""" |
| 333 | assert "CloudTrail" in self._src or "cloudtrail" in self._src.lower() |
| 334 | |
| 335 | |
| 336 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 337 | # Settings: production guards (ensure 7.1 guards still in place for 7.2 context) |
| 338 | # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| 339 | |
| 340 | class TestProductionGuardsForSecrets: |
| 341 | def test_weak_passwords_known_to_startup_guard(self) -> None: |
| 342 | """The set of weak passwords checked at startup must include common defaults.""" |
| 343 | src = _ROOT.joinpath("musehub", "main.py").read_text() |
| 344 | for weak in ["musehub", "changeme", "password"]: |
| 345 | assert weak in src, ( |
| 346 | f"Startup guard does not block {weak!r} as a weak DB_PASSWORD" |
| 347 | ) |
| 348 | |
| 349 | def test_fernet_key_format_documented_in_runbook(self) -> None: |
| 350 | """Runbook must show Fernet.generate_key() so operators use the right format.""" |
| 351 | assert "Fernet.generate_key" in _ROTATION_RUNBOOK.read_text() |