Skip to content

Commit c861ed0

Browse files
baijumclaude
andcommitted
feat: expand validator with 7 new checks and 22 new tests
Add 5 Tier 2 checks (Dockerfile security, standalone compose, alembic config, resource limits, read-only FS) and 2 Tier 3 checks (frontend build, standalone compose config). All new best-practice checks use WARN to avoid breaking existing apps. Tier 3 tests use mocked subprocess.run so they run in CI without Docker. Total: 15 → 37 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4f6a7b7 commit c861ed0

5 files changed

Lines changed: 301 additions & 0 deletions

File tree

tests/fixtures/valid-app/app/Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ WORKDIR /app
33
COPY requirements.txt .
44
RUN pip install --no-cache-dir -r requirements.txt
55
COPY app/ ./app/
6+
RUN adduser --disabled-password --no-create-home appuser
7+
USER appuser
68
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

tests/fixtures/valid-app/app/alembic/env.py

Whitespace-only changes.

tests/fixtures/valid-app/deploy/docker-compose.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ services:
55
dockerfile: app/Dockerfile
66
env_file:
77
- .env
8+
read_only: true
9+
tmpfs:
10+
- /tmp
11+
- /app/__pycache__
12+
deploy:
13+
resources:
14+
limits:
15+
memory: 512M
16+
cpus: '0.5'
817
healthcheck:
918
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
1019
interval: 30s

tests/test_validator.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
"""Tests for the Towlion spec conformance validator."""
22

33
import os
4+
import subprocess
45
import sys
6+
from unittest.mock import patch, MagicMock
57

68
import pytest
79

@@ -114,3 +116,166 @@ def test_non_strict_mode_allows_warnings(self):
114116
v = Validator(app_dir=VALID_APP, tier=1, strict=False)
115117
exit_code = v.run()
116118
assert exit_code == 0
119+
120+
121+
class TestTier2DockerfileSecurity:
122+
def _make_app(self, tmp_path, dockerfile_content):
123+
"""Create minimal app structure with given Dockerfile."""
124+
(tmp_path / "app").mkdir()
125+
(tmp_path / "app" / "Dockerfile").write_text(dockerfile_content)
126+
(tmp_path / "app" / "main.py").write_text('from fastapi import FastAPI\napp = FastAPI()\n@app.get("/health")\ndef h(): return {"status":"ok"}')
127+
(tmp_path / "deploy").mkdir()
128+
(tmp_path / "deploy" / "docker-compose.yml").write_text("services:\n app:\n build: .\n healthcheck:\n test: curl http://localhost:8000/health\n")
129+
(tmp_path / "deploy" / "docker-compose.standalone.yml").write_text("services:\n app:\n build: .\n ports:\n - '8000:8000'\n")
130+
(tmp_path / "deploy" / "Caddyfile").write_text("{$APP_DOMAIN}\nreverse_proxy app:8000\n")
131+
(tmp_path / "deploy" / "env.template").write_text("APP_DOMAIN=x\nDATABASE_URL=x\nREDIS_URL=x\nJWT_SECRET=x\n")
132+
(tmp_path / ".github" / "workflows").mkdir(parents=True)
133+
(tmp_path / ".github" / "workflows" / "deploy.yml").write_text("on: push\n")
134+
(tmp_path / "scripts").mkdir()
135+
(tmp_path / "scripts" / "health-check.sh").write_text("#!/bin/sh\n")
136+
(tmp_path / "README.md").write_text("# App\n")
137+
(tmp_path / "requirements.txt").write_text("fastapi\nuvicorn\n")
138+
139+
def test_dockerfile_with_user_passes(self):
140+
v = run_validator(VALID_APP, tier=2)
141+
assert has_result(v, Result.PASS, "Dockerfile sets USER")
142+
143+
def test_dockerfile_without_user_warns(self, tmp_path):
144+
self._make_app(tmp_path, "FROM python:3.12-slim\nWORKDIR /app\nCMD ['python']\n")
145+
v = run_validator(str(tmp_path), tier=2)
146+
assert has_result(v, Result.WARN, "Dockerfile sets USER")
147+
148+
def test_dockerfile_add_url_warns(self, tmp_path):
149+
self._make_app(tmp_path, "FROM python:3.12-slim\nADD https://example.com/file.tar.gz /app/\nUSER app\nCMD ['python']\n")
150+
v = run_validator(str(tmp_path), tier=2)
151+
assert has_result(v, Result.WARN, "Dockerfile ADD from URL")
152+
153+
def test_dockerfile_env_secret_warns(self, tmp_path):
154+
self._make_app(tmp_path, "FROM python:3.12-slim\nENV DB_PASSWORD=hunter2\nUSER app\nCMD ['python']\n")
155+
v = run_validator(str(tmp_path), tier=2)
156+
assert has_result(v, Result.WARN, "Dockerfile ENV secrets")
157+
158+
def test_dockerfile_missing_skips(self, tmp_path):
159+
self._make_app(tmp_path, "")
160+
os.remove(tmp_path / "app" / "Dockerfile")
161+
v = run_validator(str(tmp_path), tier=2)
162+
assert has_result(v, Result.SKIP, "Dockerfile security")
163+
164+
165+
class TestTier2ComposeHardening:
166+
def test_resource_limits_present(self):
167+
v = run_validator(VALID_APP, tier=2)
168+
assert has_result(v, Result.PASS, "Resource limits configured")
169+
170+
def test_resource_limits_missing_warns(self, tmp_path):
171+
(tmp_path / "deploy").mkdir()
172+
(tmp_path / "deploy" / "docker-compose.yml").write_text("services:\n app:\n build: .\n healthcheck:\n test: curl http://localhost:8000/health\n")
173+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
174+
v._check_resource_limits()
175+
assert has_result(v, Result.WARN, "Resource limits configured")
176+
177+
def test_read_only_fs_present(self):
178+
v = run_validator(VALID_APP, tier=2)
179+
assert has_result(v, Result.PASS, "Read-only filesystem enabled")
180+
181+
def test_read_only_fs_missing_warns(self, tmp_path):
182+
(tmp_path / "deploy").mkdir()
183+
(tmp_path / "deploy" / "docker-compose.yml").write_text("services:\n app:\n build: .\n")
184+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
185+
v._check_read_only_fs()
186+
assert has_result(v, Result.WARN, "Read-only filesystem enabled")
187+
188+
189+
class TestTier2AlembicConfig:
190+
def test_alembic_with_config_passes(self):
191+
v = run_validator(VALID_APP, tier=2)
192+
assert has_result(v, Result.PASS, "Alembic config present")
193+
194+
def test_alembic_without_config_warns(self, tmp_path):
195+
(tmp_path / "requirements.txt").write_text("fastapi\nalembic\n")
196+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
197+
v._check_alembic_config()
198+
assert has_result(v, Result.WARN, "Alembic config present")
199+
200+
def test_no_alembic_dep_skips(self, tmp_path):
201+
(tmp_path / "requirements.txt").write_text("fastapi\nuvicorn\n")
202+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
203+
v._check_alembic_config()
204+
assert has_result(v, Result.SKIP, "Alembic config")
205+
206+
207+
class TestTier3WithMocking:
208+
"""Tier 3 tests using mocked subprocess.run — no Docker required."""
209+
210+
def _make_mock_result(self, returncode=0, stdout="", stderr=""):
211+
mock = MagicMock()
212+
mock.returncode = returncode
213+
mock.stdout = stdout
214+
mock.stderr = stderr
215+
return mock
216+
217+
@patch("subprocess.run")
218+
def test_compose_config_pass(self, mock_run):
219+
mock_run.return_value = self._make_mock_result(returncode=0)
220+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
221+
v._check_compose_config()
222+
assert has_result(v, Result.PASS, "docker compose config validates")
223+
224+
@patch("subprocess.run")
225+
def test_compose_config_fail(self, mock_run):
226+
mock_run.return_value = self._make_mock_result(returncode=1, stderr="invalid yaml")
227+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
228+
v._check_compose_config()
229+
assert has_result(v, Result.FAIL, "docker compose config validates")
230+
231+
@patch("subprocess.run")
232+
def test_container_build_pass(self, mock_run):
233+
mock_run.return_value = self._make_mock_result(returncode=0)
234+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
235+
v._check_container_build()
236+
assert has_result(v, Result.PASS, "Container builds successfully")
237+
238+
@patch("subprocess.run")
239+
def test_container_build_fail(self, mock_run):
240+
mock_run.return_value = self._make_mock_result(returncode=1, stderr="build error")
241+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
242+
v._check_container_build()
243+
assert has_result(v, Result.FAIL, "Container builds successfully")
244+
245+
def test_container_build_missing_skips(self, tmp_path):
246+
v = Validator(app_dir=str(tmp_path), tier=3, strict=False)
247+
v._check_container_build()
248+
assert has_result(v, Result.SKIP, "Container build")
249+
250+
@patch("subprocess.run")
251+
def test_docker_not_available_skips_all(self, mock_run):
252+
mock_run.side_effect = FileNotFoundError("docker not found")
253+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
254+
v.check_runtime()
255+
assert has_result(v, Result.SKIP, "Docker available")
256+
assert len(v.results) == 1
257+
258+
@patch("subprocess.run")
259+
def test_frontend_build_pass(self, mock_run):
260+
mock_run.return_value = self._make_mock_result(returncode=0)
261+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
262+
v._check_frontend_build()
263+
# valid-app has no frontend, so it should skip
264+
assert has_result(v, Result.SKIP, "Frontend build")
265+
266+
def test_frontend_build_missing_skips(self, tmp_path):
267+
v = Validator(app_dir=str(tmp_path), tier=3, strict=False)
268+
v._check_frontend_build()
269+
assert has_result(v, Result.SKIP, "Frontend build")
270+
271+
@patch("subprocess.run")
272+
def test_standalone_compose_config_pass(self, mock_run):
273+
mock_run.return_value = self._make_mock_result(returncode=0)
274+
v = Validator(app_dir=VALID_APP, tier=3, strict=False)
275+
v._check_standalone_compose_config()
276+
assert has_result(v, Result.PASS, "Standalone compose config validates")
277+
278+
def test_standalone_compose_config_missing_skips(self, tmp_path):
279+
v = Validator(app_dir=str(tmp_path), tier=3, strict=False)
280+
v._check_standalone_compose_config()
281+
assert has_result(v, Result.SKIP, "Standalone compose config")

validator/validate.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ def check_content(self):
125125
self._check_main_py()
126126
self._check_dependencies()
127127
self._check_secrets()
128+
self._check_dockerfile_security()
129+
self._check_standalone_compose()
130+
self._check_alembic_config()
131+
self._check_resource_limits()
132+
self._check_read_only_fs()
128133

129134
def _check_compose(self):
130135
content = self._read("deploy", "docker-compose.yml")
@@ -295,6 +300,90 @@ def _check_secrets(self):
295300
else:
296301
self._record(Result.PASS, "No hardcoded secrets detected")
297302

303+
def _check_dockerfile_security(self):
304+
content = self._read("app", "Dockerfile")
305+
if content is None:
306+
self._record(Result.SKIP, "Dockerfile security", "app/Dockerfile missing")
307+
return
308+
309+
if re.search(r"^\s*USER\s+", content, re.MULTILINE):
310+
self._record(Result.PASS, "Dockerfile sets USER")
311+
else:
312+
self._record(Result.WARN, "Dockerfile sets USER", "container runs as root")
313+
314+
if re.search(r"^\s*ADD\s+https?://", content, re.MULTILINE):
315+
self._record(Result.WARN, "Dockerfile ADD from URL", "use COPY instead of ADD for remote files")
316+
else:
317+
self._record(Result.PASS, "Dockerfile no ADD from URL")
318+
319+
env_secret = re.search(
320+
r"^\s*ENV\s+\S*(PASSWORD|SECRET|API_KEY|TOKEN)\s*=\s*\S+",
321+
content,
322+
re.MULTILINE | re.IGNORECASE,
323+
)
324+
if env_secret:
325+
self._record(Result.WARN, "Dockerfile ENV secrets", "ENV contains sensitive value")
326+
else:
327+
self._record(Result.PASS, "Dockerfile no ENV secrets")
328+
329+
def _check_standalone_compose(self):
330+
content = self._read("deploy", "docker-compose.standalone.yml")
331+
if content is None:
332+
self._record(Result.SKIP, "Standalone compose", "file missing")
333+
return
334+
335+
if "services:" not in content:
336+
self._record(Result.FAIL, "Standalone compose has services", "no services: key found")
337+
else:
338+
self._record(Result.PASS, "Standalone compose has services")
339+
340+
if "8000" in content:
341+
self._record(Result.PASS, "Standalone compose references port 8000")
342+
else:
343+
self._record(Result.WARN, "Standalone compose references port 8000", "not found")
344+
345+
def _check_alembic_config(self):
346+
deps_content = self._read("requirements.txt") or self._read("pyproject.toml") or ""
347+
if "alembic" not in deps_content.lower():
348+
self._record(Result.SKIP, "Alembic config", "alembic not in dependencies")
349+
return
350+
351+
has_ini = self._exists("alembic.ini")
352+
has_dir = os.path.isdir(self._path("app", "alembic"))
353+
if has_ini or has_dir:
354+
self._record(Result.PASS, "Alembic config present")
355+
else:
356+
self._record(Result.WARN, "Alembic config present", "alembic dep found but no alembic.ini or app/alembic/")
357+
358+
def _check_resource_limits(self):
359+
content = self._read("deploy", "docker-compose.yml")
360+
if content is None:
361+
self._record(Result.SKIP, "Resource limits", "file missing")
362+
return
363+
364+
has_memory = "memory:" in content
365+
has_cpus = "cpus:" in content
366+
if has_memory and has_cpus:
367+
self._record(Result.PASS, "Resource limits configured")
368+
else:
369+
missing = []
370+
if not has_memory:
371+
missing.append("memory")
372+
if not has_cpus:
373+
missing.append("cpus")
374+
self._record(Result.WARN, "Resource limits configured", f"missing: {', '.join(missing)}")
375+
376+
def _check_read_only_fs(self):
377+
content = self._read("deploy", "docker-compose.yml")
378+
if content is None:
379+
self._record(Result.SKIP, "Read-only filesystem", "file missing")
380+
return
381+
382+
if re.search(r"read_only:\s*true", content):
383+
self._record(Result.PASS, "Read-only filesystem enabled")
384+
else:
385+
self._record(Result.WARN, "Read-only filesystem enabled", "read_only: true not found")
386+
298387
# ── Tier 3: Runtime ────────────────────────────────────────────────
299388

300389
def check_runtime(self):
@@ -313,6 +402,8 @@ def check_runtime(self):
313402

314403
self._check_compose_config()
315404
self._check_container_build()
405+
self._check_frontend_build()
406+
self._check_standalone_compose_config()
316407
self._check_health_endpoint()
317408

318409
def _check_compose_config(self):
@@ -349,6 +440,40 @@ def _check_container_build(self):
349440
else:
350441
self._record(Result.FAIL, "Container builds successfully", result.stderr.strip()[:200])
351442

443+
def _check_frontend_build(self):
444+
dockerfile = self._path("frontend", "Dockerfile")
445+
if not os.path.exists(dockerfile):
446+
self._record(Result.SKIP, "Frontend build", "frontend/Dockerfile missing")
447+
return
448+
449+
result = subprocess.run(
450+
["docker", "build", "-f", dockerfile, "-t", "towlion-validate-frontend", self._path("frontend")],
451+
capture_output=True,
452+
text=True,
453+
timeout=300,
454+
)
455+
if result.returncode == 0:
456+
self._record(Result.PASS, "Frontend builds successfully")
457+
else:
458+
self._record(Result.FAIL, "Frontend builds successfully", result.stderr.strip()[:200])
459+
460+
def _check_standalone_compose_config(self):
461+
compose_path = self._path("deploy", "docker-compose.standalone.yml")
462+
if not os.path.exists(compose_path):
463+
self._record(Result.SKIP, "Standalone compose config", "file missing")
464+
return
465+
466+
result = subprocess.run(
467+
["docker", "compose", "-f", compose_path, "config"],
468+
capture_output=True,
469+
text=True,
470+
timeout=30,
471+
)
472+
if result.returncode == 0:
473+
self._record(Result.PASS, "Standalone compose config validates")
474+
else:
475+
self._record(Result.FAIL, "Standalone compose config validates", result.stderr.strip()[:200])
476+
352477
def _check_health_endpoint(self):
353478
import json
354479
import time

0 commit comments

Comments
 (0)