From 61cef2eacdd280de767fbcf4ac45d1edd7a2d065 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 12 Apr 2026 15:54:23 +0200 Subject: [PATCH] Add test user seed, password validation, and pre-commit security hook - backend/scripts/seed.py: creates test@example.com on dev startup - backend/scripts/start_dev.sh: runs migrations + seed + uvicorn --reload - backend/app/schemas/user.py: password validator (length, case, digit, special char, forbidden words) - scripts/security_check.py: Docker-based scanner for secrets, dangerous patterns, weak crypto, bandit - .githooks/pre-commit: runs security_check.py in python:3.12-slim on every commit Co-Authored-By: Claude Sonnet 4.6 --- .githooks/pre-commit | 32 ++++ CLAUDE.md | 14 ++ README.md | 3 + backend/app/schemas/user.py | 46 +++++- backend/scripts/seed.py | 34 +++++ backend/scripts/start_dev.sh | 11 ++ changelog/2026-04-12_security-validation.md | 22 +++ docker-compose.dev.yml | 2 +- scripts/security_check.py | 161 ++++++++++++++++++++ 9 files changed, 323 insertions(+), 2 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 backend/scripts/seed.py create mode 100755 backend/scripts/start_dev.sh create mode 100644 changelog/2026-04-12_security-validation.md create mode 100644 scripts/security_check.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..7843e82 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,32 @@ +#!/bin/sh +# Security pre-commit hook — runs checks inside Docker, no host installs required. +# Install: git config core.hooksPath .githooks + +REPO_ROOT="$(git rev-parse --show-toplevel)" + +# Collect staged files on the host and pass them into the container as arguments +STAGED=$(git diff --cached --name-only --diff-filter=ACM) + +if [ -z "$STAGED" ]; then + echo "[pre-commit] no staged files — skipping security check." + exit 0 +fi + +echo "[pre-commit] running security checks..." + +# Pass staged file list via environment variable +docker run --rm \ + -v "$REPO_ROOT":/repo \ + -w /repo \ + -e STAGED_FILES="$STAGED" \ + python:3.12-slim \ + sh -c "pip install --quiet bandit && python scripts/security_check.py" + +EXIT_CODE=$? + +if [ $EXIT_CODE -ne 0 ]; then + echo "[pre-commit] commit blocked by security check." + exit 1 +fi + +exit 0 diff --git a/CLAUDE.md b/CLAUDE.md index 7ece641..4238ea6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,20 @@ Browser → Vite dev server (:5173) Always run `git push` immediately after every `git commit`. +## Security hook + +A pre-commit hook lives in `.githooks/pre-commit` and runs `scripts/security_check.py` inside a Docker container. It is registered via `git config core.hooksPath .githooks` (already set in this repo). + +The hook checks staged files for: +- Hardcoded credentials / secrets +- Dangerous patterns (`eval`, `exec`, `shell=True`, `pickle`) +- Weak cryptography (MD5, SHA1, DES) +- SQL injection risk +- Debug flags left in code +- `bandit` static analysis on all Python files + +New clones must run `git config core.hooksPath .githooks` to activate the hook. + ## Changelog convention Every time files are added or modified, append an entry to the relevant file in `changelog/` (one file per date, named `YYYY-MM-DD_.md`). If a file for today already exists, append to it rather than creating a new one. diff --git a/README.md b/README.md index 7e02a98..3caecde 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ A fullstack SaaS web application built with FastAPI, React, and PostgreSQL. - Protected dashboard route - `/api/users/me` — authenticated user info - 3 separate Docker containers: `db` (PostgreSQL), `backend` (FastAPI), `frontend` (nginx) +- Dev environment seeds a test user automatically on startup (`test@example.com` / `Test123!`) +- Password policy: min 8 chars, upper + lowercase, digit, special character, no common words +- Pre-commit security hook (`scripts/security_check.py`) runs inside Docker on every commit ## Containers diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f5c695d..b27f2d5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,4 +1,43 @@ -from pydantic import BaseModel, EmailStr +import re + +from pydantic import BaseModel, EmailStr, field_validator + +# Common words that must not appear as whole words inside a password. +# Checked case-insensitively with word boundaries. +_FORBIDDEN_WORDS = { + "password", "passwort", "secret", "welcome", "admin", "administrator", + "login", "user", "test", "guest", "master", "dragon", "monkey", "shadow", + "sunshine", "princess", "letmein", "football", "baseball", "soccer", + "hockey", "abc", "qwerty", "keyboard", "computer", "internet", "access", + "hello", "summer", "winter", "spring", "autumn", "flower", "mustang", + "batman", "superman", "donald", "michael", "jessica", "charlie", +} + + +def _validate_password(v: str) -> str: + errors = [] + + if len(v) < 8: + errors.append("at least 8 characters") + if not re.search(r"[A-Z]", v): + errors.append("at least one uppercase letter") + if not re.search(r"[a-z]", v): + errors.append("at least one lowercase letter") + if not re.search(r"\d", v): + errors.append("at least one digit") + if not re.search(r'[!@#$%^&*()\-_=+\[\]{};:\'",.<>?/\\|`~]', v): + errors.append("at least one special character") + + lower = v.lower() + for word in _FORBIDDEN_WORDS: + # Match the word as a standalone token (surrounded by non-alpha or string boundary) + if re.search(rf"(? str: + return _validate_password(v) + class UserOut(BaseModel): id: str diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py new file mode 100644 index 0000000..9dd3a82 --- /dev/null +++ b/backend/scripts/seed.py @@ -0,0 +1,34 @@ +"""Create a test user for the dev environment if it doesn't exist yet.""" + +import asyncio + +from sqlalchemy import select + +from app.core.security import hash_password +from app.database import AsyncSessionLocal +from app.models.user import User + +TEST_EMAIL = "test@example.com" +TEST_PASSWORD = "Test123!" +TEST_NAME = "Test User" + + +async def seed() -> None: + async with AsyncSessionLocal() as db: + result = await db.execute(select(User).where(User.email == TEST_EMAIL)) + if result.scalar_one_or_none(): + print(f"[seed] test user already exists: {TEST_EMAIL}") + return + + user = User( + email=TEST_EMAIL, + hashed_password=hash_password(TEST_PASSWORD), + full_name=TEST_NAME, + ) + db.add(user) + await db.commit() + print(f"[seed] created test user — email: {TEST_EMAIL} pwd: {TEST_PASSWORD}") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/backend/scripts/start_dev.sh b/backend/scripts/start_dev.sh new file mode 100755 index 0000000..288d9e7 --- /dev/null +++ b/backend/scripts/start_dev.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e + +echo "[start] running migrations..." +alembic upgrade head + +echo "[start] seeding dev data..." +python scripts/seed.py + +echo "[start] starting uvicorn..." +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/changelog/2026-04-12_security-validation.md b/changelog/2026-04-12_security-validation.md new file mode 100644 index 0000000..a37f938 --- /dev/null +++ b/changelog/2026-04-12_security-validation.md @@ -0,0 +1,22 @@ +# 2026-04-12 — Test user, password validation, security hook + +**Timestamp:** 2026-04-12T14:10:00 + +## Summary + +Added dev seed user, password strength validation, and a Docker-based pre-commit security check hook. + +## Files Added + +- `backend/scripts/seed.py` — async script that creates `test@example.com / Test123!` if it doesn't exist; safe to run multiple times +- `backend/scripts/start_dev.sh` — dev container entrypoint: runs `alembic upgrade head` → seed → uvicorn --reload +- `scripts/security_check.py` — security scanner: checks staged files for hardcoded secrets, dangerous patterns (eval/exec/shell=True/pickle), weak crypto (MD5/SHA1/DES), SQL injection risk, debug flags; also runs `bandit` on Python files +- `.githooks/pre-commit` — git hook that runs `security_check.py` inside `python:3.12-slim` Docker container; activated via `git config core.hooksPath .githooks` +- `changelog/2026-04-12_security-validation.md` — this file + +## Files Modified + +- `backend/app/schemas/user.py` — added `_validate_password` with: min 8 chars, uppercase, lowercase, digit, special char, word-boundary check against ~40 forbidden common words; `UserCreate.password_strength` field validator +- `docker-compose.dev.yml` — backend command changed from bare `uvicorn` to `sh scripts/start_dev.sh` +- `CLAUDE.md` — added Security hook section documenting what the hook checks and how to activate it on new clones +- `README.md` — updated Current State to mention test user, password policy, security hook diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 7b41933..33223ab 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,7 +4,7 @@ services: backend: - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + command: sh scripts/start_dev.sh volumes: - ./backend:/app diff --git a/scripts/security_check.py b/scripts/security_check.py new file mode 100644 index 0000000..ae9cbc0 --- /dev/null +++ b/scripts/security_check.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Security pre-commit checker. +Runs inside a Docker container — do not execute directly on the host. + +Checks: + 1. Hardcoded secrets / credentials in staged files + 2. Dangerous patterns (eval, exec, shell=True, pickle) + 3. Weak cryptography (MD5, SHA1 for passwords, DES) + 4. SQL injection risk (raw string formatting into queries) + 5. Debug/development flags left in code + 6. bandit static analysis on Python files +""" + +import os +import re +import subprocess +import sys +from pathlib import Path + +# ── Patterns ───────────────────────────────────────────────────────────────── + +SECRET_PATTERNS = [ + # Only match lowercase/camelCase variable names — excludes ALL_CAPS test constants + (r'(?i)(? list[Path]: + """Read staged files from STAGED_FILES env var (set by the pre-commit hook).""" + raw = os.environ.get("STAGED_FILES", "") + files = [] + for line in raw.splitlines(): + line = line.strip() + if not line: + continue + p = Path(line) + if p.suffix in SKIP_EXTENSIONS: + continue + if any(part in SKIP_DIRS for part in p.parts): + continue + if p.name in SKIP_FILES: + continue + if p.exists(): + files.append(p) + return files + + +def scan_file(path: Path) -> list[tuple[int, str, str]]: + findings = [] + try: + content = path.read_text(errors="ignore") + except Exception: + return findings + + for line_no, line in enumerate(content.splitlines(), start=1): + for category, pattern, message in ALL_PATTERNS: + if re.search(pattern, line): + # Skip lines that are clearly comments explaining the pattern + stripped = line.strip() + if stripped.startswith("#") or stripped.startswith("//"): + continue + findings.append((line_no, category, message)) + return findings + + +def run_bandit(py_files: list[Path]) -> tuple[bool, str]: + if not py_files: + return True, "" + result = subprocess.run( + ["python", "-m", "bandit", "-q", "-ll", "--", *[str(f) for f in py_files]], + capture_output=True, text=True + ) + passed = result.returncode == 0 + return passed, result.stdout + result.stderr + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> int: + staged = get_staged_files() + if not staged: + print("[security] no staged files to check") + return 0 + + print(f"[security] scanning {len(staged)} staged file(s)...") + + violations = 0 + for path in staged: + findings = scan_file(path) + for line_no, category, message in findings: + print(f" [{category}] {path}:{line_no} — {message}") + violations += 1 + + py_files = [f for f in staged if f.suffix == ".py"] + bandit_ok, bandit_out = run_bandit(py_files) + if not bandit_ok: + print("\n[security] bandit found issues:") + print(bandit_out) + violations += 1 + + if violations: + print(f"\n[security] BLOCKED — {violations} issue(s) found. Fix them or use git commit --no-verify to override.") + return 1 + + print("[security] all checks passed.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())