gabriel / musehub public
test_secrets_management.py python
351 lines 17.4 KB
Raw
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor ⚠ breaking 1 day ago
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()
File History 1 commit
sha256:3ff9c9863a9891bdcde71b4a43228f66d0493e38b7cc1d09fe9eb7de774046b2 feat: add repair-commit wire endpoint (API parity with repa… Opus 4.8 minor 1 day ago