From 61dc6215016daf557d92b92b3e98677891b24629 Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Mon, 2 Mar 2026 17:18:10 +0300 Subject: [PATCH] add security test 04 --- .env.production | 10 ++++ Dockerfile | 2 +- Makefile | 15 +++++- README.md | 34 ++++++++++++- app/core/config.py | 1 + app/email_main.py | 10 +++- app/services/email_service.py | 14 ++++++ context/11_test_runbook.md | 1 + docker-compose.prod.nginx.yml | 6 +++ docker-compose.prod.yml | 6 +++ docker-compose.yml | 24 ++++++++++ scripts/ops/minio_tls_bootstrap.sh | 29 +++++++++--- scripts/ops/prod_security_audit.sh | 17 ++++++- scripts/ops/security_scheduler.sh | 76 ++++++++++++++++++++++++++++++ scripts/ops/security_smoke.sh | 9 ++++ 15 files changed, 241 insertions(+), 13 deletions(-) create mode 100755 scripts/ops/security_scheduler.sh diff --git a/.env.production b/.env.production index f5bd417..f4424f3 100644 --- a/.env.production +++ b/.env.production @@ -91,6 +91,7 @@ OTP_SMS_MIN_BALANCE=20 # Email OTP / fallback # EMAIL_PROVIDER: dummy | smtp | service # ---------------------------------------------------------------------------- +EMAIL_SERVICE_ENABLED=true EMAIL_PROVIDER=service EMAIL_SERVICE_URL=http://email-service:8010 OTP_EMAIL_FALLBACK_ENABLED=true @@ -133,3 +134,12 @@ CLAMAV_ENABLED=true CLAMAV_HOST=clamav CLAMAV_PORT=3310 CLAMAV_TIMEOUT_SECONDS=20 + +# ---------------------------------------------------------------------------- +# Security scheduler (dedicated periodic smoke entity) +# ---------------------------------------------------------------------------- +SECURITY_SCHEDULER_INTERVAL_SECONDS=900 +SECURITY_SCHEDULER_INTERNAL_BASE_URL=http://frontend +SECURITY_SCHEDULER_EXTERNAL_DOMAINS=ruakb.ru,ruakb.online +SECURITY_SCHEDULER_SKIP_DOCKER_CHECKS=1 +SECURITY_SCHEDULER_RUN_INCIDENT_ON_FAIL=1 diff --git a/Dockerfile b/Dockerfile index a3ad36e..5842a81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y build-essential curl openssl ca-certificates && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . diff --git a/Makefile b/Makefile index 6304463..8666cbe 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ prod-up prod-down prod-logs prod-ps prod-migrate \ prod-secrets-generate prod-secrets-apply \ prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid \ - security-smoke prod-security-audit \ + security-smoke prod-security-audit prod-security-scheduler-up prod-security-scheduler-logs \ prod-cert-init prod-cert-renew \ check-prod-files check-cert-files \ run migrate test seed-quotes @@ -44,6 +44,8 @@ help: @echo " incident-checklist - Create PDn incident checklist markdown report" @echo " security-smoke - Run security smoke checks and create report" @echo " prod-security-audit - Full production security audit/repair workflow" + @echo " prod-security-scheduler-up - Start/update dedicated security scheduler service" + @echo " prod-security-scheduler-logs - Tail security scheduler logs" @echo " rotate-encryption-kid - Add new KID key pair to .env and switch active KID" @echo " reencrypt-active-kid - Re-encrypt historical encrypted fields using active KID" @echo " prod-cert-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)" @@ -130,6 +132,17 @@ prod-security-audit: check-cert-files LOCAL_SMOKE_CANDIDATES="$(LOCAL_SMOKE_CANDIDATES)" \ ./scripts/ops/prod_security_audit.sh +prod-security-scheduler-up: check-prod-files + @echo "[SEC] Checking MinIO TLS bundle" + @if [ ! -f deploy/tls/minio/ca.crt ] || ! openssl x509 -in deploy/tls/minio/ca.crt -noout >/dev/null 2>&1 || [ ! -f deploy/tls/minio/public.crt ] || ! openssl x509 -in deploy/tls/minio/public.crt -noout >/dev/null 2>&1 || [ ! -f deploy/tls/minio/private.key ]; then \ + echo "[SEC] MinIO TLS bundle missing/invalid -> regenerating"; \ + MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh; \ + fi + $(PROD_COMPOSE) up -d --build --force-recreate security-scheduler + +prod-security-scheduler-logs: check-prod-files + $(PROD_COMPOSE) logs -f --tail=200 security-scheduler + rotate-encryption-kid: ./scripts/ops/rotate_encryption_kid.sh --env-file .env diff --git a/README.md b/README.md index 5c30ba5..13994d4 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,8 @@ OTP sending is implemented through a dedicated SMS service layer (`app/services/ Public auth mode can be selected via environment: ```bash PUBLIC_AUTH_MODE=sms # sms | email | sms_or_email | totp -EMAIL_PROVIDER=dummy # dummy | smtp +EMAIL_SERVICE_ENABLED=true # false -> email-service stays healthy but sending is disabled +EMAIL_PROVIDER=dummy # dummy | smtp | service EMAIL_SERVICE_URL=http://email-service:8010 INTERNAL_SERVICE_TOKEN=change_me_internal_service_token OTP_EMAIL_FALLBACK_ENABLED=true @@ -204,11 +205,17 @@ OTP_EMAIL_TEMPLATE=Ваш код подтверждения: {code} For dedicated email microservice (recommended in production): ```bash +EMAIL_SERVICE_ENABLED=true EMAIL_PROVIDER=service EMAIL_SERVICE_URL=http://email-service:8010 INTERNAL_SERVICE_TOKEN= ``` +To keep infrastructure healthy but disable email sending temporarily: +```bash +EMAIL_SERVICE_ENABLED=false +``` + Admin/Lawyer TOTP endpoints: - `GET /api/admin/auth/totp/status` - `POST /api/admin/auth/totp/setup` @@ -371,6 +378,31 @@ You can override: make prod-security-audit LOCAL_SMOKE_CANDIDATES="https://127.0.0.1,http://127.0.0.1" ``` +## Dedicated security scheduler entity +`security-scheduler` is a separate container service that runs periodic smoke checks automatically. + +Start/update: +```bash +make prod-security-scheduler-up +``` + +Logs: +```bash +make prod-security-scheduler-logs +``` + +Main env knobs: +```bash +SECURITY_SCHEDULER_INTERVAL_SECONDS=900 +SECURITY_SCHEDULER_INTERNAL_BASE_URL=http://frontend +SECURITY_SCHEDULER_EXTERNAL_DOMAINS=ruakb.ru,ruakb.online +SECURITY_SCHEDULER_SKIP_DOCKER_CHECKS=1 +SECURITY_SCHEDULER_RUN_INCIDENT_ON_FAIL=1 +``` + +Scheduler script: +- `/Users/tronosfera/Develop/Law/scripts/ops/security_scheduler.sh` + ## Container health and alerting Docker Compose is configured with: - `restart: unless-stopped` for core services diff --git a/app/core/config.py b/app/core/config.py index 1ebc2ab..7e9e52d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -65,6 +65,7 @@ class Settings(BaseSettings): OTP_AUTOTEST_FORCE_MOCK_SMS: bool = True PUBLIC_AUTH_MODE: str = "sms" # sms | email | sms_or_email | totp EMAIL_PROVIDER: str = "dummy" # dummy | smtp + EMAIL_SERVICE_ENABLED: bool = True EMAIL_SERVICE_URL: str = "http://email-service:8010" INTERNAL_SERVICE_TOKEN: str = "change_me_internal_service_token" SMTP_HOST: str = "" diff --git a/app/email_main.py b/app/email_main.py index b955a98..948c1a8 100644 --- a/app/email_main.py +++ b/app/email_main.py @@ -17,16 +17,24 @@ class InternalEmailSend(BaseModel): @app.on_event("startup") def _validate_security_config_on_startup() -> None: + if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)): + return validate_production_security_or_raise("email-service") @app.get("/health") def health(): - return {"status": "ok", "service": "email-service"} + return { + "status": "ok", + "service": "email-service", + "enabled": bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)), + } @app.post("/internal/send-otp") def internal_send_otp(payload: InternalEmailSend, x_internal_token: str | None = Header(default=None)): + if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)): + raise HTTPException(status_code=503, detail="Email service disabled") expected = str(settings.INTERNAL_SERVICE_TOKEN or "").strip() if not expected: raise HTTPException(status_code=500, detail="INTERNAL_SERVICE_TOKEN не настроен") diff --git a/app/services/email_service.py b/app/services/email_service.py index f055853..12d68f3 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -139,6 +139,9 @@ def _send_via_email_service(*, email: str, subject: str, body: str) -> dict[str, def send_otp_email_message(*, email: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: + if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)): + raise EmailDeliveryError("Email-рассылка отключена (EMAIL_SERVICE_ENABLED=false)") + normalized_email = _normalize_email(email) if not normalized_email: raise EmailDeliveryError("Некорректный email") @@ -163,6 +166,17 @@ def send_otp_email_message(*, email: str, code: str, purpose: str, track_number: def email_provider_health() -> dict[str, Any]: + if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)): + return { + "provider": "disabled", + "status": "ok", + "mode": "disabled", + "dev_mode": bool(_otp_dev_mode_enabled()), + "can_send": False, + "checks": {"email_service_enabled": False}, + "issues": ["EMAIL_SERVICE_ENABLED=false: Email-рассылка отключена"], + } + provider = str(settings.EMAIL_PROVIDER or "dummy").strip().lower() if _otp_dev_mode_enabled(): return { diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index b6d0ca2..e4fba9a 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -115,6 +115,7 @@ echo $? # 0=OK, >0=ALERT | SEC-14 | Контроль уязвимостей в CI | `.github/workflows/security-ci.yml` (`bandit`, `pip-audit`, `trivy`) | GitHub Actions: workflow `security-ci` (PR/push/schedule/manual). Локальная валидация YAML: `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/security-ci.yml')"` | | SEC-15 | Регулярный security smoke | `scripts/ops/security_smoke.sh` | `make security-smoke` или `./scripts/ops/security_smoke.sh https://ruakb.online`; проверять отчет `reports/security/security-smoke-.md` | | SEC-OPS | Полный прод-аудит безопасности | `scripts/ops/prod_security_audit.sh`, `Makefile` target `prod-security-audit` | `make prod-security-audit DOMAIN=... WWW_DOMAIN=... SECOND_DOMAIN=... SECOND_WWW_DOMAIN=... LETSENCRYPT_EMAIL=...` (`AUTO_CERT_INIT=1` при необходимости автоподнятия LE cert) | +| SEC-AUTO | Автоматизация security-smoke отдельной сущностью | `scripts/ops/security_scheduler.sh`, service `security-scheduler` в compose | `make prod-security-scheduler-up`; далее `make prod-security-scheduler-logs` и проверка health статуса контейнера | | P49 | Клиентский UI запросов (куратор/смена юриста) | e2e `e2e/tests/service_requests_flow.spec.js`, `e2e/tests/public_client_flow.spec.js` | `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js e2e/tests/public_client_flow.spec.js` | | P50 | Админ UI: вкладка `Запросы` + topbar индикатор | `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, e2e `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_metrics_templates tests.admin.test_service_requests -v` + Playwright прогон указанных spec | | P51 | Тесты контура запросов | backend: `tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`; e2e: `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js` | diff --git a/docker-compose.prod.nginx.yml b/docker-compose.prod.nginx.yml index e26c61b..90f6ba4 100644 --- a/docker-compose.prod.nginx.yml +++ b/docker-compose.prod.nginx.yml @@ -82,6 +82,12 @@ services: security_opt: - no-new-privileges:true + security-scheduler: + volumes: + - ./reports:/app/reports + security_opt: + - no-new-privileges:true + # Production: keep official ClamAV image on x86_64 hosts. clamav: platform: linux/amd64 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index e9f2a60..14afd64 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -72,6 +72,12 @@ services: security_opt: - no-new-privileges:true + security-scheduler: + volumes: + - ./reports:/app/reports + security_opt: + - no-new-privileges:true + # Production: keep official ClamAV image on x86_64 hosts. clamav: platform: linux/amd64 diff --git a/docker-compose.yml b/docker-compose.yml index c50055f..e95a1d8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -122,6 +122,30 @@ services: command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"] volumes: [".:/app"] + security-scheduler: + build: . + container_name: law-security-scheduler + restart: unless-stopped + env_file: .env + depends_on: + frontend: + condition: service_healthy + backend: + condition: service_healthy + chat-service: + condition: service_healthy + email-service: + condition: service_healthy + command: ["bash", "-lc", "./scripts/ops/security_scheduler.sh"] + healthcheck: + test: ["CMD-SHELL", "test -f /tmp/security_scheduler_heartbeat && [ $(( $(date +%s) - $(cat /tmp/security_scheduler_heartbeat) )) -lt 1800 ]"] + interval: 60s + timeout: 5s + retries: 3 + start_period: 60s + volumes: + - .:/app + db: image: postgres:16 container_name: law-db diff --git a/scripts/ops/minio_tls_bootstrap.sh b/scripts/ops/minio_tls_bootstrap.sh index 6043d14..ec3060f 100755 --- a/scripts/ops/minio_tls_bootstrap.sh +++ b/scripts/ops/minio_tls_bootstrap.sh @@ -10,14 +10,29 @@ OVERWRITE="${MINIO_TLS_OVERWRITE:-false}" mkdir -p "$OUT_DIR" -if [[ "$OVERWRITE" != "true" ]]; then - for required in ca.crt ca.key public.crt private.key; do - if [[ -f "$OUT_DIR/$required" ]]; then - echo "[ERROR] $OUT_DIR/$required already exists. Set MINIO_TLS_OVERWRITE=true to regenerate." >&2 - exit 1 +prepare_output_path() { + local path="$1" + if [[ -d "$path" ]]; then + if [[ "$OVERWRITE" == "true" ]]; then + rm -rf "$path" + return 0 fi - done -fi + echo "[ERROR] $path is a directory. Set MINIO_TLS_OVERWRITE=true to replace it." >&2 + exit 1 + fi + if [[ -f "$path" ]]; then + if [[ "$OVERWRITE" == "true" ]]; then + rm -f "$path" + return 0 + fi + echo "[ERROR] $path already exists. Set MINIO_TLS_OVERWRITE=true to regenerate." >&2 + exit 1 + fi +} + +for required in ca.crt ca.key public.crt private.key; do + prepare_output_path "$OUT_DIR/$required" +done if ! command -v openssl >/dev/null 2>&1; then echo "[ERROR] openssl not found" >&2 diff --git a/scripts/ops/prod_security_audit.sh b/scripts/ops/prod_security_audit.sh index bbffc53..d282251 100755 --- a/scripts/ops/prod_security_audit.sh +++ b/scripts/ops/prod_security_audit.sh @@ -62,7 +62,19 @@ ensure_env_file() { ensure_minio_tls_bundle() { if file_missing "deploy/tls/minio/public.crt" || file_missing "deploy/tls/minio/private.key" || file_missing "deploy/tls/minio/ca.crt"; then log "MinIO TLS bundle is missing -> generating" - ./scripts/ops/minio_tls_bootstrap.sh + MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh + return 0 + fi + + if ! openssl x509 -in "deploy/tls/minio/ca.crt" -noout >/dev/null 2>&1; then + log "MinIO CA certificate is invalid -> regenerating" + MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh + return 0 + fi + + if ! openssl x509 -in "deploy/tls/minio/public.crt" -noout >/dev/null 2>&1; then + log "MinIO public certificate is invalid -> regenerating" + MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh else log "MinIO TLS bundle present" fi @@ -82,12 +94,13 @@ stack_up_and_migrate() { "${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate db redis minio clamav log "Starting app services (backend/chat/email/worker/beat)" - "${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate backend chat-service email-service worker beat + "${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate backend chat-service email-service worker beat security-scheduler log "Waiting app services to become healthy" wait_service_healthy "backend" 60 wait_service_healthy "chat-service" 60 wait_service_healthy "email-service" 60 + wait_service_healthy "security-scheduler" 90 # Force recreate frontend/edge after chat/backend to avoid stale DNS upstream cache in nginx. log "Starting/recreating frontend and edge" diff --git a/scripts/ops/security_scheduler.sh b/scripts/ops/security_scheduler.sh new file mode 100755 index 0000000..d0b14e2 --- /dev/null +++ b/scripts/ops/security_scheduler.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -u + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +INTERVAL_SECONDS="${SECURITY_SCHEDULER_INTERVAL_SECONDS:-900}" +INTERNAL_BASE_URL="${SECURITY_SCHEDULER_INTERNAL_BASE_URL:-http://frontend}" +EXTERNAL_DOMAINS="${SECURITY_SCHEDULER_EXTERNAL_DOMAINS:-ruakb.ru,ruakb.online}" +SKIP_DOCKER_CHECKS="${SECURITY_SCHEDULER_SKIP_DOCKER_CHECKS:-1}" +RUN_INCIDENT_ON_FAIL="${SECURITY_SCHEDULER_RUN_INCIDENT_ON_FAIL:-1}" +HEARTBEAT_FILE="${SECURITY_SCHEDULER_HEARTBEAT_FILE:-/tmp/security_scheduler_heartbeat}" + +log() { + echo "[SEC-SCHEDULER] $*" +} + +run_smoke() { + local url="$1" + if SECURITY_SMOKE_SKIP_DOCKER_CHECKS="$SKIP_DOCKER_CHECKS" ./scripts/ops/security_smoke.sh "$url"; then + log "smoke ok: ${url}" + return 0 + fi + log "smoke failed: ${url}" + return 1 +} + +run_cycle() { + local failed=0 + run_smoke "$INTERNAL_BASE_URL" || failed=1 + + IFS=',' read -r -a _domains <<< "$EXTERNAL_DOMAINS" + local domain + for domain in "${_domains[@]}"; do + domain="$(echo "$domain" | xargs)" + [[ -z "$domain" ]] && continue + run_smoke "https://${domain}" || failed=1 + done + + date +%s > "$HEARTBEAT_FILE" + + if [[ "$failed" == "1" && "$RUN_INCIDENT_ON_FAIL" == "1" && -x "./scripts/ops/incident_checklist.sh" ]]; then + ./scripts/ops/incident_checklist.sh \ + --severity MEDIUM \ + --category MONITORING_ALERT \ + --summary "security-scheduler detected smoke check failure" || true + fi +} + +validate_interval() { + if ! [[ "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 60 ]]; then + log "invalid SECURITY_SCHEDULER_INTERVAL_SECONDS=${INTERVAL_SECONDS}, fallback to 900" + INTERVAL_SECONDS=900 + fi +} + +main() { + validate_interval + mkdir -p reports/security reports/incidents + log "started: interval=${INTERVAL_SECONDS}s internal=${INTERNAL_BASE_URL} external=${EXTERNAL_DOMAINS}" + while true; do + local started_at + started_at="$(date +%s)" + run_cycle + local elapsed + elapsed="$(( $(date +%s) - started_at ))" + local sleep_for + sleep_for="$(( INTERVAL_SECONDS - elapsed ))" + if (( sleep_for < 1 )); then + sleep_for=1 + fi + sleep "$sleep_for" + done +} + +main "$@" diff --git a/scripts/ops/security_smoke.sh b/scripts/ops/security_smoke.sh index 8537187..7f8f6b0 100755 --- a/scripts/ops/security_smoke.sh +++ b/scripts/ops/security_smoke.sh @@ -6,6 +6,7 @@ cd "$ROOT_DIR" BASE_URL="${1:-http://localhost:8081}" REPORT_DIR="${REPORT_DIR:-reports/security}" +SECURITY_SMOKE_SKIP_DOCKER_CHECKS="${SECURITY_SMOKE_SKIP_DOCKER_CHECKS:-0}" TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")" TS_FILE="$(date -u +"%Y%m%d-%H%M%S")" REPORT_FILE="${REPORT_DIR}/security-smoke-${TS_FILE}.md" @@ -166,6 +167,10 @@ check_cookie_and_security_flags() { } check_compose_service_running() { + if [[ "$SECURITY_SMOKE_SKIP_DOCKER_CHECKS" == "1" ]]; then + add_warn "docker checks disabled by SECURITY_SMOKE_SKIP_DOCKER_CHECKS=1" + return 0 + fi local service="$1" if ! command -v docker >/dev/null 2>&1; then add_warn "docker is not available: service checks skipped" @@ -182,6 +187,10 @@ check_compose_service_running() { } check_db_security_audit_table() { + if [[ "$SECURITY_SMOKE_SKIP_DOCKER_CHECKS" == "1" ]]; then + add_warn "db checks disabled by SECURITY_SMOKE_SKIP_DOCKER_CHECKS=1" + return 0 + fi if ! command -v docker >/dev/null 2>&1; then add_warn "docker is not available: DB checks skipped" return 0