gabriel / musehub public

test_secrets_management.py file-level

at sha256:f · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:1 Merge 'fix/test-suite-real-bugs' into 'dev' β€” proposal: fix: full test … · gabriel · Sep 7, 2026
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 = {
90 "PYTHONPATH", "PYTHONDONTWRITEBYTECODE", "PYTHONUNBUFFERED",
91 # Playwright needs its browser-binary path at build time (chromium
92 # install for OG card rendering, #129) as well as runtime β€” not a
93 # secret, just a filesystem path.
94 "PLAYWRIGHT_BROWSERS_PATH",
95 }
96 for line in self._src.splitlines():
97 stripped = line.strip()
98 if stripped.startswith("#"):
99 continue
100 if stripped.upper().startswith("ENV "):
101 env_decl = stripped[4:].strip()
102 key = re.split(r'[=\s]', env_decl)[0].upper()
103 assert key in _SAFE_VARS, (
104 f"Dockerfile sets ENV {key!r} which is not in the approved safe list "
105 f"{_SAFE_VARS}. Runtime config must come from --env-file."
106 )
107
108 def test_multi_stage_build_no_secret_in_builder(self) -> None:
109 """Builder stage must not receive secrets (they'd be in intermediate layers)."""
110 in_builder = False
111 for line in self._src.splitlines():
112 stripped = line.strip()
113 if stripped.startswith("#"):
114 continue
115 if re.match(r'^FROM\s+\S+\s+AS\s+builder', stripped, re.IGNORECASE):
116 in_builder = True
117 elif re.match(r'^FROM\s+', stripped, re.IGNORECASE):
118 in_builder = False
119 if in_builder and stripped.upper().startswith("ARG "):
120 arg_name = stripped[4:].split("=")[0].strip().upper()
121 if self._SECRET_KEYWORDS.search(arg_name):
122 pytest.fail(
123 f"Builder stage has ARG {arg_name!r} β€” "
124 "secrets in builder layers are never truly deleted"
125 )
126
127
128 # ═══════════════════════════════════════════════════════════════════════════════
129 # Runtime injection: deploy.sh uses --env-file, not baked-in secrets
130 # ═══════════════════════════════════════════════════════════════════════════════
131
132 class TestRuntimeInjection:
133 _src = _DEPLOY_SH.read_text()
134
135 def test_deploy_uses_env_file_flag(self) -> None:
136 """deploy.sh must pass --env-file to docker run (runtime injection)."""
137 assert "--env-file" in self._src, (
138 "deploy.sh does not use --env-file β€” secrets may be baked in or missing"
139 )
140
141 def test_deploy_does_not_hardcode_passwords(self) -> None:
142 """deploy.sh must not contain hardcoded secret values."""
143 for line in self._src.splitlines():
144 stripped = line.strip()
145 if stripped.startswith("#"):
146 continue
147 # Reject lines like DB_PASSWORD="literal_value" where value is not
148 # a variable reference or command substitution
149 m = re.search(r'(DB_PASSWORD|SECRET_KEY|RUNNER_TOKEN|API_KEY)\s*=\s*["\']?([^${\s"\']+)["\']?', stripped)
150 if m:
151 value = m.group(2)
152 # Allow variable references like ${DB_PASSWORD} or command subs
153 if not value.startswith("$") and not value.startswith("`"):
154 pytest.fail(
155 f"deploy.sh appears to hardcode {m.group(1)}={value!r} β€” "
156 "secrets must come from .env, not be inline in the script"
157 )
158
159 def test_deploy_reads_env_from_app_dir(self) -> None:
160 """deploy.sh must read .env from the app directory, not from the repo."""
161 assert "/opt/musehub/.env" in self._src or "APP_DIR" in self._src, (
162 "deploy.sh does not reference a server-side .env path"
163 )
164
165 def test_docker_compose_does_not_inline_secrets(self) -> None:
166 """docker-compose.yml must not contain literal (non-variable) secret values.
167
168 Variable references like ${DB_PASSWORD} or ${DB_PASSWORD:-default} are fine β€”
169 they expand at runtime from the .env file. Literal strings are not.
170 """
171 src = _COMPOSE.read_text()
172 _SECRET_KEY = re.compile(
173 r'\b(DB_PASSWORD|SECRET_KEY|RUNNER_TOKEN|BLOB_STORAGE_SECRET_ACCESS_KEY)\b',
174 re.IGNORECASE,
175 )
176 for line in src.splitlines():
177 stripped = line.strip()
178 if stripped.startswith("#"):
179 continue
180 if not _SECRET_KEY.search(stripped):
181 continue
182 # Extract the value portion (after the first = or :)
183 value_part = re.split(r'[=:]', stripped, maxsplit=1)[-1].strip().strip('"\'')
184 # Allow: variable references ($VAR, ${VAR}, ${VAR:-default})
185 # Reject: any non-empty literal that doesn't contain ${ or $
186 if value_part and "$" not in value_part:
187 pytest.fail(
188 f"docker-compose.yml has literal secret value on line: {line!r}\n"
189 "Use ${{VAR}} references that expand from the .env file at runtime."
190 )
191
192
193 # ═══════════════════════════════════════════════════════════════════════════════
194 # SSM secrets script
195 # ═══════════════════════════════════════════════════════════════════════════════
196
197 class TestSecretsScript:
198 _src = _SECRETS_SH.read_text()
199
200 def test_secrets_sh_exists(self) -> None:
201 assert _SECRETS_SH.exists(), "deploy/secrets.sh must exist"
202
203 def test_secrets_sh_is_valid_shell(self) -> None:
204 """secrets.sh must pass bash syntax check."""
205 result = subprocess.run(
206 ["bash", "-n", str(_SECRETS_SH)],
207 capture_output=True, text=True,
208 )
209 assert result.returncode == 0, (
210 f"deploy/secrets.sh has bash syntax errors:\n{result.stderr}"
211 )
212
213 def test_secrets_sh_reads_from_ssm(self) -> None:
214 """secrets.sh must call aws ssm get-parameter (not inline secrets)."""
215 assert "ssm get-parameter" in self._src or "ssm get-parameters-by-path" in self._src
216
217 def test_secrets_sh_uses_with_decryption(self) -> None:
218 """SSM fetch must use --with-decryption (SecureString parameters)."""
219 assert "--with-decryption" in self._src
220
221 def test_secrets_sh_does_not_hardcode_secrets(self) -> None:
222 """secrets.sh must not contain literal secret values."""
223 for line in self._src.splitlines():
224 stripped = line.strip()
225 if stripped.startswith("#"):
226 continue
227 # Reject if a secret-sounding variable is assigned a non-variable literal
228 m = re.search(
229 r'(DB_PASSWORD|WEBHOOK_SECRET_KEY|RUNNER_TOKEN|BLOB_STORAGE_SECRET)\s*=\s*["\']([^$][^"\']{7,})["\']',
230 stripped,
231 )
232 if m:
233 pytest.fail(
234 f"secrets.sh hardcodes {m.group(1)!r} β€” must read from SSM"
235 )
236
237 def test_secrets_sh_validates_password_strength(self) -> None:
238 """secrets.sh must sanity-check the fetched DB_PASSWORD."""
239 assert "weak" in self._src.lower() or "WEAK" in self._src, (
240 "secrets.sh does not validate DB_PASSWORD strength β€” "
241 "a weak value from SSM would pass silently"
242 )
243
244 def test_secrets_sh_writes_mode_600_env(self) -> None:
245 """secrets.sh must write .env with restricted permissions (umask 177 or chmod)."""
246 assert "umask 177" in self._src or "chmod 600" in self._src, (
247 "secrets.sh does not set restrictive permissions on .env β€” "
248 "other users on the host could read it"
249 )
250
251 def test_secrets_sh_fails_on_error(self) -> None:
252 """secrets.sh must use set -euo pipefail."""
253 assert "set -euo pipefail" in self._src or "set -e" in self._src
254
255
256 # ═══════════════════════════════════════════════════════════════════════════════
257 # setup-ec2.sh: generates fresh secrets, not defaults
258 # ═══════════════════════════════════════════════════════════════════════════════
259
260 class TestSetupEc2GeneratesSecrets:
261 def test_setup_ec2_generates_db_password(self) -> None:
262 """setup-ec2.sh must generate a fresh DB password, not use a default."""
263 src = _SETUP_EC2.read_text()
264 # Must use openssl rand or similar
265 assert "openssl rand" in src, (
266 "setup-ec2.sh does not generate a fresh DB_PASSWORD β€” "
267 "operators would use the weak default from .env.example"
268 )
269
270 def test_setup_ec2_generates_webhook_key(self) -> None:
271 """setup-ec2.sh must generate a Fernet key for WEBHOOK_SECRET_KEY."""
272 src = _SETUP_EC2.read_text()
273 assert "Fernet" in src or "fernet" in src, (
274 "setup-ec2.sh does not generate a WEBHOOK_SECRET_KEY β€” "
275 "webhook encryption would be disabled on fresh installs"
276 )
277
278 def test_setup_ec2_does_not_commit_weak_defaults(self) -> None:
279 """setup-ec2.sh must not write known-weak passwords to .env."""
280 src = _SETUP_EC2.read_text()
281 _WEAK = ["DB_PASSWORD=musehub", "DB_PASSWORD=changeme", "DB_PASSWORD=password"]
282 for weak in _WEAK:
283 assert weak not in src, (
284 f"setup-ec2.sh writes weak password: {weak!r}"
285 )
286
287 def test_setup_staging_generates_secrets(self) -> None:
288 """setup-ec2-staging.sh must also generate secrets for staging."""
289 src = _SETUP_STG.read_text()
290 # Staging must either generate secrets or reference secrets.sh
291 has_generation = "openssl rand" in src or "secrets.sh" in src or "Fernet" in src
292 assert has_generation, (
293 "setup-ec2-staging.sh does not generate secrets β€” "
294 "staging would start with default/empty credentials"
295 )
296
297
298 # ═══════════════════════════════════════════════════════════════════════════════
299 # Rotation runbook
300 # ═══════════════════════════════════════════════════════════════════════════════
301
302 class TestRotationRunbook:
303 _src = _ROTATION_RUNBOOK.read_text()
304
305 def test_runbook_exists(self) -> None:
306 assert _ROTATION_RUNBOOK.exists(), "docs/secret-rotation-runbook.md must exist"
307
308 def test_runbook_covers_db_password(self) -> None:
309 assert "DB_PASSWORD" in self._src
310
311 def test_runbook_covers_webhook_key(self) -> None:
312 assert "WEBHOOK_SECRET_KEY" in self._src
313
314 def test_runbook_covers_runner_token(self) -> None:
315 assert "RUNNER_TOKEN" in self._src
316
317 def test_runbook_documents_db_rotation_schedule(self) -> None:
318 """DB password must have a rotation schedule (180 days)."""
319 assert "180" in self._src, (
320 "Runbook does not document 180-day DB password rotation schedule"
321 )
322
323 def test_runbook_documents_compromise_response(self) -> None:
324 """Runbook must cover what to do on compromise."""
325 assert re.search(r'comprom', self._src, re.IGNORECASE), (
326 "Runbook does not cover compromise response"
327 )
328
329 def test_runbook_documents_ssm_commands(self) -> None:
330 """Runbook must show actual ssm put-parameter commands."""
331 assert "ssm put-parameter" in self._src
332
333 def test_runbook_documents_docker_image_audit(self) -> None:
334 """Runbook must document how to audit Docker image layers."""
335 assert "docker history" in self._src
336
337 def test_runbook_documents_cloudtrail_audit(self) -> None:
338 """Runbook must reference CloudTrail for SSM access auditing."""
339 assert "CloudTrail" in self._src or "cloudtrail" in self._src.lower()
340
341
342 # ═══════════════════════════════════════════════════════════════════════════════
343 # Settings: production guards (ensure 7.1 guards still in place for 7.2 context)
344 # ═══════════════════════════════════════════════════════════════════════════════
345
346 class TestProductionGuardsForSecrets:
347 def test_weak_passwords_known_to_startup_guard(self) -> None:
348 """The set of weak passwords checked at startup must include common defaults."""
349 src = _ROOT.joinpath("musehub", "main.py").read_text()
350 for weak in ["musehub", "changeme", "password"]:
351 assert weak in src, (
352 f"Startup guard does not block {weak!r} as a weak DB_PASSWORD"
353 )
354
355 def test_fernet_key_format_documented_in_runbook(self) -> None:
356 """Runbook must show Fernet.generate_key() so operators use the right format."""
357 assert "Fernet.generate_key" in _ROTATION_RUNBOOK.read_text()