diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..f5bd417 --- /dev/null +++ b/.env.production @@ -0,0 +1,135 @@ +# ============================================================================ +# Production environment template for Legal Case Tracker +# Copy to ".env" on production host and replace ALL placeholder values. +# Never commit real secrets. +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Core +# ---------------------------------------------------------------------------- +APP_ENV=prod +PRODUCTION_ENFORCE_SECURE_SETTINGS=true +APP_NAME=legal-case-tracker + +# ---------------------------------------------------------------------------- +# JWT / Cookies / Origin checks +# ---------------------------------------------------------------------------- +PUBLIC_JWT_TTL_DAYS=7 +ADMIN_JWT_TTL_MINUTES=240 +ADMIN_JWT_SECRET=REPLACE_WITH_LONG_RANDOM_ADMIN_JWT_SECRET_64PLUS +PUBLIC_JWT_SECRET=REPLACE_WITH_LONG_RANDOM_PUBLIC_JWT_SECRET_64PLUS +PUBLIC_COOKIE_NAME=public_jwt +PUBLIC_COOKIE_SECURE=true +PUBLIC_COOKIE_SAMESITE=lax +PUBLIC_STRICT_ORIGIN_CHECK=true +PUBLIC_ALLOWED_WEB_ORIGINS=https://ruakb.online,https://www.ruakb.online +CORS_ORIGINS=https://ruakb.online,https://www.ruakb.online +CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Requested-With,X-Request-ID +CORS_ALLOW_CREDENTIALS=true + +# ---------------------------------------------------------------------------- +# Database / Redis +# Keep DATABASE_URL and POSTGRES_* password in sync. +# ---------------------------------------------------------------------------- +POSTGRES_USER=postgres +POSTGRES_PASSWORD=REPLACE_WITH_STRONG_POSTGRES_PASSWORD +POSTGRES_DB=legal +DATABASE_URL=postgresql+psycopg://postgres:REPLACE_WITH_STRONG_POSTGRES_PASSWORD@db:5432/legal +REDIS_URL=redis://redis:6379/0 + +# ---------------------------------------------------------------------------- +# Storage (S3 / MinIO) +# ---------------------------------------------------------------------------- +S3_ENDPOINT=https://minio:9000 +S3_ACCESS_KEY=REPLACE_WITH_STRONG_MINIO_ACCESS_KEY +S3_SECRET_KEY=REPLACE_WITH_STRONG_MINIO_SECRET_KEY +S3_BUCKET=legal-files +S3_REGION=us-east-1 +S3_USE_SSL=true +S3_VERIFY_SSL=true +S3_CA_CERT_PATH=/etc/ssl/minio/ca.crt +MAX_FILE_MB=25 +MAX_CASE_MB=250 +MINIO_ROOT_USER=REPLACE_WITH_NON_DEFAULT_MINIO_USER +MINIO_ROOT_PASSWORD=REPLACE_WITH_STRONG_MINIO_ROOT_PASSWORD +MINIO_TLS_ENABLED=true + +# ---------------------------------------------------------------------------- +# Data encryption +# ---------------------------------------------------------------------------- +DATA_ENCRYPTION_ACTIVE_KID=k202603 +DATA_ENCRYPTION_KEYS=k202603=REPLACE_WITH_LONG_RANDOM_DATA_KID_SECRET_64PLUS +CHAT_ENCRYPTION_ACTIVE_KID=k202603 +CHAT_ENCRYPTION_KEYS=k202603=REPLACE_WITH_LONG_RANDOM_CHAT_KID_SECRET_64PLUS +DATA_ENCRYPTION_SECRET=REPLACE_WITH_LONG_RANDOM_DATA_ENCRYPTION_SECRET_64PLUS +CHAT_ENCRYPTION_SECRET=REPLACE_WITH_LONG_RANDOM_CHAT_ENCRYPTION_SECRET_64PLUS +INTERNAL_SERVICE_TOKEN=REPLACE_WITH_LONG_RANDOM_INTERNAL_SERVICE_TOKEN_64PLUS + +# ---------------------------------------------------------------------------- +# OTP / Public auth mode +# PUBLIC_AUTH_MODE: sms | email | sms_or_email | totp +# ---------------------------------------------------------------------------- +PUBLIC_AUTH_MODE=sms_or_email +OTP_DEV_MODE=false +OTP_AUTOTEST_FORCE_MOCK_SMS=true +OTP_RATE_LIMIT_WINDOW_SECONDS=300 +OTP_SEND_RATE_LIMIT=8 +OTP_VERIFY_RATE_LIMIT=20 + +# ---------------------------------------------------------------------------- +# SMS provider +# SMS_PROVIDER: dummy | smsaero +# ---------------------------------------------------------------------------- +SMS_PROVIDER=smsaero +SMSAERO_EMAIL=REPLACE_WITH_SMSAERO_ACCOUNT_EMAIL +SMSAERO_API_KEY=REPLACE_WITH_SMSAERO_API_KEY +OTP_SMS_TEMPLATE=Ваш код подтверждения: {code} +OTP_SMS_MIN_BALANCE=20 + +# ---------------------------------------------------------------------------- +# Email OTP / fallback +# EMAIL_PROVIDER: dummy | smtp | service +# ---------------------------------------------------------------------------- +EMAIL_PROVIDER=service +EMAIL_SERVICE_URL=http://email-service:8010 +OTP_EMAIL_FALLBACK_ENABLED=true +OTP_EMAIL_SUBJECT_TEMPLATE=Код подтверждения: {code} +OTP_EMAIL_TEMPLATE=Ваш код подтверждения: {code} + +# SMTP mode settings (only if EMAIL_PROVIDER=smtp) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=no-reply@example.com +SMTP_PASSWORD=REPLACE_WITH_SMTP_PASSWORD +SMTP_FROM=no-reply@example.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false + +# ---------------------------------------------------------------------------- +# Admin auth / bootstrap +# ADMIN_BOOTSTRAP_ENABLED must be false in production. +# ---------------------------------------------------------------------------- +ADMIN_AUTH_MODE=password_totp_required +TOTP_ISSUER=Правовой Трекер +ADMIN_BOOTSTRAP_ENABLED=false +ADMIN_BOOTSTRAP_EMAIL=admin@example.com +ADMIN_BOOTSTRAP_PASSWORD=REPLACE_WITH_TEMP_BOOTSTRAP_PASSWORD +ADMIN_BOOTSTRAP_NAME=Администратор системы + +# ---------------------------------------------------------------------------- +# Telegram notifications +# ---------------------------------------------------------------------------- +TELEGRAM_BOT_TOKEN=REPLACE_WITH_TELEGRAM_BOT_TOKEN +TELEGRAM_CHAT_ID=REPLACE_WITH_TELEGRAM_CHAT_ID + +# ---------------------------------------------------------------------------- +# Attachment security scan (ClamAV) +# ---------------------------------------------------------------------------- +ATTACHMENT_SCAN_ENABLED=true +ATTACHMENT_SCAN_ENFORCE=true +ATTACHMENT_ALLOWED_MIME_TYPES=application/pdf,image/jpeg,image/png,video/mp4,text/plain +CLAMAV_ENABLED=true +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 diff --git a/.github/workflows/security-ci.yml b/.github/workflows/security-ci.yml new file mode 100644 index 0000000..f0042d7 --- /dev/null +++ b/.github/workflows/security-ci.yml @@ -0,0 +1,183 @@ +name: security-ci + +on: + workflow_dispatch: + pull_request: + push: + branches: + - master + - main + schedule: + - cron: "17 2 * * 1" + +permissions: + contents: read + security-events: write + +env: + BANDIT_MAX_HIGH: "0" + DEP_MAX_VULNS: "0" + TRIVY_MAX_HIGH: "0" + TRIVY_MAX_CRITICAL: "0" + +jobs: + sast-and-dependencies: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install scanners + run: | + python -m pip install --upgrade pip + pip install bandit pip-audit + + - name: Prepare reports directory + run: mkdir -p reports/security + + - name: Run Bandit (SAST) + run: | + bandit -r app scripts -f json -o reports/security/bandit.json || true + + - name: Run pip-audit (dependencies) + run: | + pip-audit -r requirements.txt --format json --output reports/security/pip-audit.json || true + + - name: Build SAST/dependency summary + id: sast_deps_summary + run: | + set -euo pipefail + + BANDIT_HIGH=$(jq '[.results[]? | select(.issue_severity == "HIGH")] | length' reports/security/bandit.json) + DEP_VULNS=$(jq ' + if type == "array" then length + elif has("vulnerabilities") then (.vulnerabilities | length) + elif has("dependencies") then ([.dependencies[]?.vulns[]?] | length) + else 0 + end + ' reports/security/pip-audit.json) + + { + echo "SAST/Dependency Security Summary" + echo "bandit.high=${BANDIT_HIGH}" + echo "deps.vulns=${DEP_VULNS}" + echo "threshold.bandit.high=${BANDIT_MAX_HIGH}" + echo "threshold.deps.vulns=${DEP_MAX_VULNS}" + } | tee reports/security/sast-deps-summary.txt + + echo "bandit_high=${BANDIT_HIGH}" >> "$GITHUB_OUTPUT" + echo "dep_vulns=${DEP_VULNS}" >> "$GITHUB_OUTPUT" + + - name: Upload SAST/dependency reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-sast-deps-reports + path: | + reports/security/bandit.json + reports/security/pip-audit.json + reports/security/sast-deps-summary.txt + if-no-files-found: error + retention-days: 30 + + - name: Enforce SAST/dependency thresholds + run: | + set -euo pipefail + BANDIT_HIGH="${{ steps.sast_deps_summary.outputs.bandit_high }}" + DEP_VULNS="${{ steps.sast_deps_summary.outputs.dep_vulns }}" + + if [ "${BANDIT_HIGH}" -gt "${BANDIT_MAX_HIGH}" ]; then + echo "Bandit HIGH findings: ${BANDIT_HIGH} (max ${BANDIT_MAX_HIGH})" + exit 1 + fi + if [ "${DEP_VULNS}" -gt "${DEP_MAX_VULNS}" ]; then + echo "Dependency vulnerabilities: ${DEP_VULNS} (max ${DEP_MAX_VULNS})" + exit 1 + fi + + container-scan: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build backend image + run: | + docker build -t law-backend-security:${{ github.sha }} . + + - name: Prepare reports directory + run: mkdir -p reports/security + + - name: Run Trivy image scan (JSON report) + uses: aquasecurity/trivy-action@0.24.0 + with: + image-ref: law-backend-security:${{ github.sha }} + format: json + output: reports/security/trivy-image.json + severity: HIGH,CRITICAL + exit-code: "0" + + - name: Run Trivy image scan (SARIF) + uses: aquasecurity/trivy-action@0.24.0 + with: + image-ref: law-backend-security:${{ github.sha }} + format: sarif + output: reports/security/trivy-image.sarif + severity: HIGH,CRITICAL + exit-code: "0" + + - name: Build container scan summary + id: trivy_summary + run: | + set -euo pipefail + TRIVY_HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' reports/security/trivy-image.json) + TRIVY_CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' reports/security/trivy-image.json) + + { + echo "Container Security Summary" + echo "trivy.high=${TRIVY_HIGH}" + echo "trivy.critical=${TRIVY_CRITICAL}" + echo "threshold.trivy.high=${TRIVY_MAX_HIGH}" + echo "threshold.trivy.critical=${TRIVY_MAX_CRITICAL}" + } | tee reports/security/trivy-summary.txt + + echo "trivy_high=${TRIVY_HIGH}" >> "$GITHUB_OUTPUT" + echo "trivy_critical=${TRIVY_CRITICAL}" >> "$GITHUB_OUTPUT" + + - name: Upload Trivy reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: security-container-reports + path: | + reports/security/trivy-image.json + reports/security/trivy-image.sarif + reports/security/trivy-summary.txt + if-no-files-found: error + retention-days: 30 + + - name: Upload SARIF to Security tab + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: reports/security/trivy-image.sarif + + - name: Enforce container scan thresholds + run: | + set -euo pipefail + TRIVY_HIGH="${{ steps.trivy_summary.outputs.trivy_high }}" + TRIVY_CRITICAL="${{ steps.trivy_summary.outputs.trivy_critical }}" + + if [ "${TRIVY_HIGH}" -gt "${TRIVY_MAX_HIGH}" ]; then + echo "Trivy HIGH findings: ${TRIVY_HIGH} (max ${TRIVY_MAX_HIGH})" + exit 1 + fi + if [ "${TRIVY_CRITICAL}" -gt "${TRIVY_MAX_CRITICAL}" ]; then + echo "Trivy CRITICAL findings: ${TRIVY_CRITICAL} (max ${TRIVY_MAX_CRITICAL})" + exit 1 + fi diff --git a/.gitignore b/.gitignore index bdb5b0d..8ec54a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /tmp/ *.idea .env +.env.prod +.env.backup.* /reports node_modules/ e2e/node_modules/ @@ -8,4 +10,5 @@ e2e/playwright-report/ e2e/test-results/ celerybeat-schedule celerybeat-schedule.* - +deploy/tls/minio/* +!deploy/tls/minio/.gitkeep diff --git a/Makefile b/Makefile index abd894e..57ddb64 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,9 @@ help \ local-up local-down local-logs local-migrate local-test local-seed \ 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 \ prod-cert-init prod-cert-renew \ check-prod-files check-cert-files \ run migrate test seed-quotes @@ -11,6 +14,8 @@ WWW_DOMAIN ?= www.ruakb.ru SECOND_DOMAIN ?= ruakb.online SECOND_WWW_DOMAIN ?= www.ruakb.online LETSENCRYPT_EMAIL ?= admin@ruakb.ru +AUTO_CERT_INIT ?= 0 +CONFIRM_TOKEN ?= ROTATE-PROD-SECRETS CERTBOT_DOMAINS = -d "$(DOMAIN)" -d "$(WWW_DOMAIN)" $(if $(strip $(SECOND_DOMAIN)),-d "$(SECOND_DOMAIN)") $(if $(strip $(SECOND_WWW_DOMAIN)),-d "$(SECOND_WWW_DOMAIN)") LOCAL_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.local.yml @@ -30,6 +35,14 @@ help: @echo " prod-logs - Tail production logs" @echo " prod-ps - Show production services" @echo " prod-migrate - Apply migrations (prod)" + @echo " prod-secrets-generate - Generate rotated internal secrets into .env.prod" + @echo " prod-secrets-apply - Generate + apply rotated internal secrets to running prod stack" + @echo " prod-minio-tls-init - Generate internal CA and MinIO TLS certs (deploy/tls/minio)" + @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 " 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)" @echo " prod-cert-renew - Renew existing certificates" @echo "" @@ -38,6 +51,7 @@ help: @echo " WWW_DOMAIN=$(WWW_DOMAIN)" @echo " SECOND_DOMAIN=$(SECOND_DOMAIN)" @echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)" + @echo " AUTO_CERT_INIT=$(AUTO_CERT_INIT)" local-up: $(LOCAL_COMPOSE) up -d --build @@ -59,6 +73,8 @@ local-seed: check-prod-files: @test -f docker-compose.prod.nginx.yml || (echo "[ERROR] Missing docker-compose.prod.nginx.yml. Run: git pull"; exit 1) + @test -f frontend/nginx.prod.conf || (echo "[ERROR] Missing frontend/nginx.prod.conf. Run: git pull"; exit 1) + @test -f scripts/ops/minio_tls_bootstrap.sh || (echo "[ERROR] Missing scripts/ops/minio_tls_bootstrap.sh. Run: git pull"; exit 1) check-cert-files: check-prod-files @test -f docker-compose.prod.cert.yml || (echo "[ERROR] Missing docker-compose.prod.cert.yml. Run: git pull"; exit 1) @@ -81,6 +97,36 @@ prod-ps: check-prod-files prod-migrate: check-prod-files $(PROD_COMPOSE) exec -T backend alembic upgrade head +prod-secrets-generate: + ./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod + +prod-secrets-apply: check-prod-files + ./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod --apply-running --compose-override docker-compose.prod.nginx.yml --non-interactive --require-confirmation-token "$(CONFIRM_TOKEN)" + +prod-minio-tls-init: + ./scripts/ops/minio_tls_bootstrap.sh + +incident-checklist: + ./scripts/ops/incident_checklist.sh + +security-smoke: + ./scripts/ops/security_smoke.sh + +prod-security-audit: check-cert-files + DOMAIN="$(DOMAIN)" \ + WWW_DOMAIN="$(WWW_DOMAIN)" \ + SECOND_DOMAIN="$(SECOND_DOMAIN)" \ + SECOND_WWW_DOMAIN="$(SECOND_WWW_DOMAIN)" \ + LETSENCRYPT_EMAIL="$(LETSENCRYPT_EMAIL)" \ + AUTO_CERT_INIT="$(AUTO_CERT_INIT)" \ + ./scripts/ops/prod_security_audit.sh + +rotate-encryption-kid: + ./scripts/ops/rotate_encryption_kid.sh --env-file .env + +reencrypt-active-kid: + docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply + # Initial certificate bootstrap: # 1) Start stack with edge nginx on port 80 only. # 2) Obtain cert via certbot webroot challenge. diff --git a/README.md b/README.md index af07b99..ca48c0e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ Email service health (via nginx): http://localhost:8081/email-health ## Production (ruakb.ru + ruakb.online, 80/443, TLS via Nginx + Certbot) Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`). +Use production template before first deploy: +```bash +cp .env.production .env +``` + Prerequisites: - DNS `A` record: `ruakb.ru -> 45.150.36.116` - Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116` @@ -26,6 +31,46 @@ Prerequisites: - `DATABASE_URL=postgresql+psycopg://postgres:@db:5432/legal` - `POSTGRES_PASSWORD=` +Production security baseline in `.env`: +```bash +APP_ENV=prod +PRODUCTION_ENFORCE_SECURE_SETTINGS=true +OTP_DEV_MODE=false +ADMIN_BOOTSTRAP_ENABLED=false +PUBLIC_COOKIE_SECURE=true +PUBLIC_COOKIE_SAMESITE=lax +PUBLIC_ALLOWED_WEB_ORIGINS=https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online,https://www.ruakb.online +CORS_ORIGINS=https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online,https://www.ruakb.online +CORS_ALLOW_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS +CORS_ALLOW_HEADERS=Authorization,Content-Type,X-Requested-With,X-Request-ID +S3_USE_SSL=true +S3_VERIFY_SSL=true +S3_CA_CERT_PATH=/etc/ssl/minio/ca.crt +MINIO_TLS_ENABLED=true +CHAT_ENCRYPTION_SECRET= +DATA_ENCRYPTION_SECRET= +DATA_ENCRYPTION_ACTIVE_KID= +DATA_ENCRYPTION_KEYS== +CHAT_ENCRYPTION_ACTIVE_KID= +CHAT_ENCRYPTION_KEYS== +ADMIN_JWT_SECRET= +PUBLIC_JWT_SECRET= +INTERNAL_SERVICE_TOKEN= +MINIO_ROOT_USER= +MINIO_ROOT_PASSWORD= +``` + +Initialize internal TLS for MinIO before first production start: +```bash +make prod-minio-tls-init +``` +This generates: +- `deploy/tls/minio/public.crt` +- `deploy/tls/minio/private.key` +- `deploy/tls/minio/ca.crt` + +Production frontend/backend/worker trust `deploy/tls/minio/ca.crt` and use HTTPS to MinIO inside docker network. + Initial certificate issue (bootstrap with nginx on port 80 only): ```bash make prod-cert-init LETSENCRYPT_EMAIL=you@example.com DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru @@ -44,19 +89,69 @@ Regular production start/update: make prod-up ``` +`make prod-up` includes strict preflight checks (`scripts/ops/deploy_prod.sh`) and fails on insecure production env values +(weak/default secrets, localhost origins, disabled strict origin checks, non-TOTP-required admin auth, etc.). + Certificate renew: ```bash make prod-cert-renew ``` +Internal secret rotation (excluding external providers such as SMS/Telegram): +```bash +make prod-secrets-generate +``` +This creates `.env.prod` with new generated internal secrets. + +Apply generated secrets to running production stack: +```bash +make prod-secrets-apply +``` +This will backup current `.env`, replace it with `.env.prod`, rotate Postgres password in DB, +recreate containers, run migrations, and execute health checks. + +Encryption KID rotation (without data loss): +```bash +make rotate-encryption-kid +# restart backend/chat/worker +make reencrypt-active-kid +``` +`rotate-encryption-kid` appends a new `kid=secret` into `DATA_ENCRYPTION_KEYS` and `CHAT_ENCRYPTION_KEYS` +and switches `*_ACTIVE_KID` to the new value. +`reencrypt-active-kid` re-encrypts historical invoice requisites, admin TOTP secrets, and chat message bodies. + +Safety guard for apply mode: +- script requires confirmation token `ROTATE-PROD-SECRETS`; +- default `make prod-secrets-apply` passes it via `CONFIRM_TOKEN`; +- you can override explicitly: +```bash +make prod-secrets-apply CONFIRM_TOKEN=ROTATE-PROD-SECRETS +``` + Checks: ```bash curl -I https://ruakb.ru curl -fsS https://ruakb.ru/health curl -fsS https://ruakb.ru/chat-health +docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml exec -T backend sh -lc 'python - <.md`. + ## Migrations ```bash docker compose exec backend alembic upgrade head @@ -179,6 +274,10 @@ CLAMAV_PORT=3310 CLAMAV_TIMEOUT_SECONDS=20 ``` +Compose profiles by environment: +- local (`docker-compose.local.yml`): `clamav` uses `mkodockx/docker-clamav:alpine` (multi-arch, including arm64). +- prod (`docker-compose.prod*.yml`): `clamav` stays on official `clamav/clamav` with `platform: linux/amd64`. + Scan statuses on `attachments`: - `PENDING` (file uploaded, scan in progress) - `CLEAN` (safe to download) @@ -187,6 +286,75 @@ Scan statuses on `attachments`: When `ATTACHMENT_SCAN_ENFORCE=true`, public/admin download endpoints block non-clean files. +## Security CI pipeline (SEC-14) +GitHub Actions workflow: `/Users/tronosfera/Develop/Law/.github/workflows/security-ci.yml` + +Checks: +- SAST: `bandit` for `app/` and `scripts/`. +- Dependency scan: `pip-audit` for `requirements.txt`. +- Container scan: `trivy` on backend Docker image. + +Fail thresholds (configurable in workflow `env`): +- `BANDIT_MAX_HIGH` (default `0`) +- `DEP_MAX_VULNS` (default `0`) +- `TRIVY_MAX_HIGH` (default `0`) +- `TRIVY_MAX_CRITICAL` (default `0`) + +Artifacts uploaded for each run: +- `reports/security/bandit.json` +- `reports/security/pip-audit.json` +- `reports/security/sast-deps-summary.txt` +- `reports/security/trivy-image.json` +- `reports/security/trivy-image.sarif` +- `reports/security/trivy-summary.txt` + +## Security smoke (SEC-15) +Local/manual run: +```bash +make security-smoke +``` +or with explicit target URL: +```bash +./scripts/ops/security_smoke.sh https://ruakb.online +``` + +What is checked: +- public health endpoints (`/health`, `/chat-health`, `/email-health`); +- required security headers (`X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Content-Security-Policy`); +- TLS certificate availability/expiry (for `https://` URLs); +- production cookie/origin env flags (`PUBLIC_COOKIE_SECURE`, `PUBLIC_COOKIE_SAMESITE`, `PUBLIC_STRICT_ORIGIN_CHECK`); +- attachment scan service availability (`clamav`, when scan is enabled); +- DB access to `security_audit_log` (table exists + query works). + +Report is saved to: +- `reports/security/security-smoke-.md` + +Example cron (every 15 minutes): +```bash +*/15 * * * * cd /opt/Law && ./scripts/ops/security_smoke.sh https://ruakb.online >> /var/log/law-security-smoke.log 2>&1 +``` + +## Full production security audit and auto-repair +Single command to verify full security block and auto-generate missing technical artifacts: +```bash +make prod-security-audit DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru SECOND_DOMAIN=ruakb.online SECOND_WWW_DOMAIN=www.ruakb.online LETSENCRYPT_EMAIL=you@example.com +``` + +What this workflow does (`scripts/ops/prod_security_audit.sh`): +- checks required compose/nginx files; +- restores/generates `.env` if missing (`.env.prod` or `.env.production` + `rotate_prod_secrets.sh`); +- generates MinIO internal TLS bundle if missing (`prod-minio-tls-init` equivalent); +- starts/reconciles production stack (nginx profile); +- applies DB migrations; +- validates production security config (`validate_production_security_or_raise`); +- runs local and external security smoke checks; +- generates incident checklist snapshot report. + +Optional: auto-bootstrap Let's Encrypt certs if HTTPS health is failing: +```bash +make prod-security-audit AUTO_CERT_INIT=1 DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru SECOND_DOMAIN=ruakb.online SECOND_WWW_DOMAIN=www.ruakb.online LETSENCRYPT_EMAIL=you@example.com +``` + ## Container health and alerting Docker Compose is configured with: - `restart: unless-stopped` for core services diff --git a/alembic/versions/0031_pii_retention_and_consent.py b/alembic/versions/0031_pii_retention_and_consent.py new file mode 100644 index 0000000..69915d7 --- /dev/null +++ b/alembic/versions/0031_pii_retention_and_consent.py @@ -0,0 +1,137 @@ +"""add pdn consent fields and data retention policies + +Revision ID: 0031_pii_retention_and_consent +Revises: 0030_attachment_scan +Create Date: 2026-03-02 +""" + +from datetime import datetime, timezone +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "0031_pii_retention_and_consent" +down_revision = "0030_attachment_scan" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "requests", + sa.Column("pdn_consent", sa.Boolean(), nullable=False, server_default=sa.text("false")), + ) + op.add_column( + "requests", + sa.Column("pdn_consent_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "requests", + sa.Column("pdn_consent_ip", sa.String(length=64), nullable=True), + ) + + op.create_table( + "data_retention_policies", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("responsible", sa.String(length=200), nullable=False, server_default="Администратор системы"), + sa.Column("entity", sa.String(length=80), nullable=False, unique=True), + sa.Column("retention_days", sa.Integer(), nullable=False, server_default="365"), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("hard_delete", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("description", sa.String(length=300), nullable=True), + ) + op.create_index( + "ix_data_retention_policies_entity", + "data_retention_policies", + ["entity"], + unique=True, + ) + + now = datetime.now(timezone.utc) + policies = sa.table( + "data_retention_policies", + sa.column("id", postgresql.UUID(as_uuid=True)), + sa.column("created_at", sa.DateTime(timezone=True)), + sa.column("updated_at", sa.DateTime(timezone=True)), + sa.column("responsible", sa.String(length=200)), + sa.column("entity", sa.String(length=80)), + sa.column("retention_days", sa.Integer()), + sa.column("enabled", sa.Boolean()), + sa.column("hard_delete", sa.Boolean()), + sa.column("description", sa.String(length=300)), + ) + op.bulk_insert( + policies, + [ + { + "id": uuid.UUID("8c3d4b50-2f11-4ec4-a993-a7f5af6f45b1"), + "created_at": now, + "updated_at": now, + "responsible": "Администратор системы", + "entity": "otp_sessions", + "retention_days": 1, + "enabled": True, + "hard_delete": True, + "description": "Одноразовые коды и технические OTP-сессии", + }, + { + "id": uuid.UUID("9c9e89b2-a9ee-4f1f-b80b-8f93a3f227c2"), + "created_at": now, + "updated_at": now, + "responsible": "Администратор системы", + "entity": "notifications", + "retention_days": 120, + "enabled": True, + "hard_delete": True, + "description": "Уведомления пользователей/сотрудников", + }, + { + "id": uuid.UUID("3ee2d4fb-42a0-48f5-a5b4-5f5f0ebebea7"), + "created_at": now, + "updated_at": now, + "responsible": "Администратор системы", + "entity": "audit_log", + "retention_days": 365, + "enabled": True, + "hard_delete": True, + "description": "Операционный аудит изменений сущностей", + }, + { + "id": uuid.UUID("4f6c95ff-7c6d-43a4-bdb5-d0d764649f22"), + "created_at": now, + "updated_at": now, + "responsible": "Администратор системы", + "entity": "security_audit_log", + "retention_days": 365, + "enabled": True, + "hard_delete": True, + "description": "Журнал безопасности и доступа к ПДн", + }, + { + "id": uuid.UUID("8a3600f9-a89a-4ff2-b2a1-0bf248f7377a"), + "created_at": now, + "updated_at": now, + "responsible": "Администратор системы", + "entity": "requests", + "retention_days": 3650, + "enabled": False, + "hard_delete": True, + "description": "Терминальные заявки (выключено по умолчанию)", + }, + ], + ) + + op.alter_column("requests", "pdn_consent", server_default=None) + + +def downgrade(): + op.drop_index("ix_data_retention_policies_entity", table_name="data_retention_policies") + op.drop_table("data_retention_policies") + op.drop_column("requests", "pdn_consent_ip") + op.drop_column("requests", "pdn_consent_at") + op.drop_column("requests", "pdn_consent") diff --git a/app/api/admin/chat.py b/app/api/admin/chat.py index 3ef294c..b1a2bea 100644 --- a/app/api/admin/chat.py +++ b/app/api/admin/chat.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest from sqlalchemy.orm import Session from app.core.deps import require_role @@ -26,11 +26,35 @@ from app.services.chat_secure_service import ( serialize_messages_for_request, ) from app.services.chat_presence import list_typing_presence, set_typing_presence +from app.services.security_audit import extract_client_ip, record_pii_access_event router = APIRouter() ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"} +def _audit_admin_chat_read( + db: Session, + *, + admin: dict, + http_request: FastapiRequest, + req: Request, + action: str, + details: dict | None = None, +) -> None: + record_pii_access_event( + db, + actor_role=str(admin.get("role") or "ADMIN").upper(), + actor_subject=str(admin.get("sub") or admin.get("email") or ""), + actor_ip=extract_client_ip(http_request), + action=action, + scope="CHAT", + request_id=req.id, + details=details or {}, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + persist_now=True, + ) + + def _parse_cursor(raw: str | None) -> datetime | None: value = str(raw or "").strip() if not value: @@ -223,13 +247,23 @@ def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement @router.get("/requests/{request_id}/messages") def list_request_messages( request_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): req = _request_for_id_or_404(db, request_id) _ensure_lawyer_can_view_request_or_403(admin, req) rows = list_messages_for_request(db, req.id) - return {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)} + payload = {"rows": serialize_messages_for_request(db, req.id, rows), "total": len(rows)} + _audit_admin_chat_read( + db, + admin=admin, + http_request=http_request, + req=req, + action="READ_CHAT_MESSAGES", + details={"rows": len(rows)}, + ) + return payload @router.post("/requests/{request_id}/messages", status_code=201) @@ -268,6 +302,7 @@ def create_request_message( @router.get("/requests/{request_id}/live") def get_request_live_state( request_id: str, + http_request: FastapiRequest, cursor: str | None = None, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), @@ -284,7 +319,7 @@ def get_request_live_state( actor_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN" actor_key = f"{actor_role}:{actor_sub}" typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key) - return { + payload = { "request_id": str(req.id), "cursor": latest_activity_iso, "has_updates": has_updates, @@ -299,6 +334,15 @@ def get_request_live_state( request_id=req.id, ), } + _audit_admin_chat_read( + db, + admin=admin, + http_request=http_request, + req=req, + action="READ_CHAT_LIVE_STATE", + details={"has_updates": bool(has_updates)}, + ) + return payload @router.post("/requests/{request_id}/typing") @@ -374,6 +418,7 @@ def list_data_request_templates( def get_data_request_batch( request_id: str, message_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): @@ -394,13 +439,22 @@ def get_data_request_batch( ) if not rows: raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден") - return { + payload = { "message_id": str(message.id), "request_id": str(req.id), "track_number": req.track_number, "document_name": rows[0].document_name if rows else None, "items": _serialize_data_request_items(db, rows), } + _audit_admin_chat_read( + db, + admin=admin, + http_request=http_request, + req=req, + action="READ_CHAT_DATA_REQUEST", + details={"message_id": str(message.id), "rows": len(rows)}, + ) + return payload @router.get("/requests/{request_id}/data-request-templates/{template_id}") diff --git a/app/api/admin/crud_modules/access.py b/app/api/admin/crud_modules/access.py index 0971a28..1003d25 100644 --- a/app/api/admin/crud_modules/access.py +++ b/app/api/admin/crud_modules/access.py @@ -52,6 +52,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = { "form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "clients": {"ADMIN": set(CRUD_ACTIONS)}, "table_availability": {"ADMIN": set(CRUD_ACTIONS)}, + "data_retention_policies": {"ADMIN": set(CRUD_ACTIONS)}, "audit_log": {"ADMIN": {"query", "read"}}, "security_audit_log": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}}, diff --git a/app/api/admin/crud_modules/meta.py b/app/api/admin/crud_modules/meta.py index fbb4f19..2638043 100644 --- a/app/api/admin/crud_modules/meta.py +++ b/app/api/admin/crud_modules/meta.py @@ -84,6 +84,7 @@ def _table_label(table_name: str) -> str: "form_fields": "Поля формы", "clients": "Клиенты", "table_availability": "Доступность таблиц", + "data_retention_policies": "Политики хранения ПДн", "topic_required_fields": "Обязательные поля темы", "topic_data_templates": "Дополнительные данные", "request_data_templates": "Шаблоны доп. данных", @@ -101,6 +102,9 @@ def _table_label(table_name: str) -> str: "request_service_requests": "Запросы", "otp_sessions": "OTP-сессии", "notifications": "Уведомления", + "retention": "хранения", + "policy": "политика", + "policies": "политики", } if normalized in explicit_labels: return explicit_labels[normalized] @@ -250,6 +254,11 @@ def _column_label(table_name: str, column_name: str) -> str: "required_data_keys": "Обязательные данные шага", "required_mime_types": "Обязательные файлы шага", "avatar_url": "Аватар", + "pdn_consent": "Согласие на ПДн", + "pdn_consent_at": "Дата согласия ПДн", + "pdn_consent_ip": "IP согласия", + "retention_days": "Срок хранения (дней)", + "hard_delete": "Жесткое удаление", "file_name": "Имя файла", "mime_type": "MIME-тип", "size_bytes": "Размер (байт)", diff --git a/app/api/admin/invoices.py b/app/api/admin/invoices.py index becaa01..3344124 100644 --- a/app/api/admin/invoices.py +++ b/app/api/admin/invoices.py @@ -5,7 +5,7 @@ from datetime import datetime, timezone from decimal import Decimal from uuid import UUID, uuid4 -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest from fastapi.responses import StreamingResponse from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session @@ -18,6 +18,7 @@ from app.models.request import Request from app.schemas.universal import UniversalQuery from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites from app.services.invoice_pdf import build_invoice_pdf_bytes +from app.services.security_audit import extract_client_ip, record_pii_access_event from app.services.universal_query import apply_universal_query router = APIRouter() @@ -193,6 +194,7 @@ def _commit_or_400(db: Session, detail: str) -> None: @router.post("/query") def query_invoices( uq: UniversalQuery, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): @@ -223,12 +225,25 @@ def query_invoices( ) for row in rows ] - return {"rows": data, "total": int(total)} + payload = {"rows": data, "total": int(total)} + record_pii_access_event( + db, + actor_role=role, + actor_subject=str(admin.get("sub") or admin.get("email") or ""), + actor_ip=extract_client_ip(http_request), + action="READ_INVOICE_LIST", + scope="INVOICE", + details={"rows": int(total)}, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + persist_now=True, + ) + return payload @router.get("/{invoice_id}") def get_invoice( invoice_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): @@ -245,12 +260,25 @@ def get_invoice( _ensure_lawyer_owns_request_or_403(role, actor_id, req) issuer = db.get(AdminUser, invoice.issued_by_admin_user_id) if invoice.issued_by_admin_user_id else None - return _serialize_invoice( + payload = _serialize_invoice( invoice, request_track=req.track_number, issuer_name=issuer.name if issuer else None, include_payer_details=True, ) + record_pii_access_event( + db, + actor_role=role, + actor_subject=str(admin.get("sub") or admin.get("email") or ""), + actor_ip=extract_client_ip(http_request), + action="READ_INVOICE_CARD", + scope="INVOICE", + request_id=req.id, + details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number}, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + persist_now=True, + ) + return payload @router.post("", status_code=201) @@ -406,6 +434,7 @@ def delete_invoice( @router.get("/{invoice_id}/pdf") def download_invoice_pdf( invoice_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), admin: dict = Depends(require_role("ADMIN", "LAWYER")), ): @@ -435,6 +464,18 @@ def download_invoice_pdf( issued_by_name=(issuer.name if issuer else None), requisites=requisites, ) + record_pii_access_event( + db, + actor_role=role, + actor_subject=str(admin.get("sub") or admin.get("email") or ""), + actor_ip=extract_client_ip(http_request), + action="READ_INVOICE_PDF", + scope="INVOICE", + request_id=req.id, + details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number}, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + persist_now=True, + ) file_name = f"{invoice.invoice_number}.pdf" headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} diff --git a/app/api/admin/requests_modules/router.py b/app/api/admin/requests_modules/router.py index 5c9ff1a..3f445e6 100644 --- a/app/api/admin/requests_modules/router.py +++ b/app/api/admin/requests_modules/router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request as FastapiRequest from sqlalchemy.orm import Session from app.core.deps import require_role @@ -15,6 +15,7 @@ from app.schemas.admin import ( RequestStatusChange, ) from app.schemas.universal import UniversalQuery +from app.services.security_audit import extract_client_ip, record_pii_access_event from .data_templates import ( create_request_data_requirement_service, @@ -80,8 +81,26 @@ def delete_request(request_id: str, db: Session = Depends(get_db), admin=Depends @router.get("/{request_id}") -def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))): - return get_request_service(request_id, db, admin) +def get_request( + request_id: str, + http_request: FastapiRequest, + db: Session = Depends(get_db), + admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), +): + payload = get_request_service(request_id, db, admin) + record_pii_access_event( + db, + actor_role=str(admin.get("role") or "ADMIN").upper(), + actor_subject=str(admin.get("sub") or admin.get("email") or ""), + actor_ip=extract_client_ip(http_request), + action="READ_REQUEST_CARD", + scope="REQUEST_CARD", + request_id=payload.get("id"), + details={"track_number": payload.get("track_number")}, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + persist_now=True, + ) + return payload @router.post("/{request_id}/status-change") @@ -97,10 +116,24 @@ def change_request_status( @router.get("/{request_id}/status-route") def get_request_status_route( request_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), ): - return get_request_status_route_service(request_id, db, admin) + payload = get_request_status_route_service(request_id, db, admin) + record_pii_access_event( + db, + actor_role=str(admin.get("role") or "ADMIN").upper(), + actor_subject=str(admin.get("sub") or admin.get("email") or ""), + actor_ip=extract_client_ip(http_request), + action="READ_REQUEST_STATUS_ROUTE", + scope="REQUEST_STATUS_ROUTE", + request_id=payload.get("request_id"), + details={"track_number": payload.get("track_number")}, + responsible=str(admin.get("email") or "").strip() or "Администратор системы", + persist_now=True, + ) + return payload @router.post("/{request_id}/claim") diff --git a/app/api/public/chat.py b/app/api/public/chat.py index c1a559f..f547626 100644 --- a/app/api/public/chat.py +++ b/app/api/public/chat.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import datetime, timezone from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request as FastapiRequest from sqlalchemy.orm import Session from app.core.deps import get_public_session @@ -22,6 +22,8 @@ from app.services.chat_secure_service import ( serialize_messages_for_request, ) from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer +from app.services.origin_guard import enforce_public_origin_or_403 +from app.services.security_audit import extract_client_ip, record_pii_access_event router = APIRouter() @@ -102,6 +104,38 @@ def _require_view_session_or_403(session: dict) -> str: return subject +def _public_actor_subject(session: dict) -> str: + subject = _require_view_session_or_403(session) + normalized_track = _normalize_track(subject) + if normalized_track.startswith("TRK-"): + return normalized_track + normalized_phone = _normalize_phone(subject) + return normalized_phone or subject + + +def _audit_public_chat_read( + db: Session, + *, + session: dict, + http_request: FastapiRequest, + req: Request, + action: str, + details: dict | None = None, +) -> None: + record_pii_access_event( + db, + actor_role="CLIENT", + actor_subject=_public_actor_subject(session), + actor_ip=extract_client_ip(http_request), + action=action, + scope="CHAT", + request_id=req.id, + details=details or {}, + responsible="Клиент", + persist_now=True, + ) + + def _request_for_track_or_404(db: Session, track_number: str) -> Request: req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first() if req is None: @@ -124,22 +158,34 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None: @router.get("/requests/{track_number}/messages") def list_messages_by_track( track_number: str, + http_request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) rows = list_messages_for_request(db, req.id) - return serialize_messages_for_request(db, req.id, rows) + payload = serialize_messages_for_request(db, req.id, rows) + _audit_public_chat_read( + db, + session=session, + http_request=http_request, + req=req, + action="READ_CHAT_MESSAGES", + details={"rows": len(rows)}, + ) + return payload @router.post("/requests/{track_number}/messages", status_code=201) def create_message_by_track( track_number: str, payload: PublicMessageCreate, + http_request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(http_request, endpoint="/api/public/chat/requests/{track_number}/messages") req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) row = create_client_message(db, request=req, body=payload.body) @@ -149,6 +195,7 @@ def create_message_by_track( @router.get("/requests/{track_number}/live") def get_live_chat_state_by_track( track_number: str, + http_request: FastapiRequest, cursor: str | None = None, db: Session = Depends(get_db), session: dict = Depends(get_public_session), @@ -164,7 +211,7 @@ def get_live_chat_state_by_track( subject = _require_view_session_or_403(session) actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}" typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key) - return { + payload = { "track_number": req.track_number, "cursor": latest_activity_iso, "has_updates": has_updates, @@ -179,15 +226,26 @@ def get_live_chat_state_by_track( request_id=req.id, ), } + _audit_public_chat_read( + db, + session=session, + http_request=http_request, + req=req, + action="READ_CHAT_LIVE_STATE", + details={"has_updates": bool(has_updates)}, + ) + return payload @router.post("/requests/{track_number}/typing") def set_live_chat_typing_by_track( track_number: str, payload: dict, + http_request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(http_request, endpoint="/api/public/chat/requests/{track_number}/typing") req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) subject = _require_view_session_or_403(session) @@ -207,6 +265,7 @@ def set_live_chat_typing_by_track( def get_data_request_by_message( track_number: str, message_id: str, + http_request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -230,7 +289,7 @@ def get_data_request_by_message( ) if not rows: raise HTTPException(status_code=404, detail="Запрос данных не найден") - return { + payload = { "message_id": str(message.id), "request_id": str(req.id), "track_number": req.track_number, @@ -248,6 +307,15 @@ def get_data_request_by_message( for row in rows ], } + _audit_public_chat_read( + db, + session=session, + http_request=http_request, + req=req, + action="READ_CHAT_DATA_REQUEST", + details={"message_id": str(message.id), "rows": len(rows)}, + ) + return payload @router.post("/requests/{track_number}/data-requests/{message_id}") @@ -255,9 +323,14 @@ def save_data_request_values( track_number: str, message_id: str, payload: dict, + http_request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403( + http_request, + endpoint="/api/public/chat/requests/{track_number}/data-requests/{message_id}", + ) req = _request_for_track_or_404(db, track_number) _ensure_view_access_or_403(session, req) try: diff --git a/app/api/public/otp.py b/app/api/public/otp.py index 3588738..5ab2bac 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -14,6 +14,7 @@ from app.models.otp_session import OtpSession from app.models.request import Request as RequestModel from app.schemas.public import OtpSend, OtpVerify from app.services.email_service import EmailDeliveryError, send_otp_email_message +from app.services.origin_guard import enforce_public_origin_or_403 from app.services.rate_limit import get_rate_limiter from app.services.sms_service import SmsDeliveryError, send_otp_message, sms_provider_health @@ -76,6 +77,10 @@ def _normalize_channel(raw: str | None) -> str: return "" +def _is_honeypot_tripped(raw: str | None) -> bool: + return bool(str(raw or "").strip()) + + def _auth_mode() -> str: mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower() if mode not in SUPPORTED_AUTH_MODES: @@ -184,8 +189,8 @@ def _set_public_cookie(response: Response, *, subject: str, purpose: str, auth_c key=settings.PUBLIC_COOKIE_NAME, value=token, httponly=True, - secure=False, - samesite="lax", + secure=settings.public_cookie_secure_effective, + samesite=settings.public_cookie_samesite_effective, max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600, ) @@ -211,6 +216,9 @@ def get_auth_config(): @router.post("/send") def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): + enforce_public_origin_or_403(request, endpoint="/api/public/otp/send") + if _is_honeypot_tripped(getattr(payload, "hp_field", None)): + raise HTTPException(status_code=400, detail="Некорректный запрос") purpose = _normalize_purpose(payload.purpose) if purpose not in ALLOWED_PURPOSES: raise HTTPException(status_code=400, detail="Некорректная цель OTP") @@ -344,6 +352,7 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): @router.post("/verify") def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)): + enforce_public_origin_or_403(request, endpoint="/api/public/otp/verify") purpose = _normalize_purpose(payload.purpose) if purpose not in ALLOWED_PURPOSES: raise HTTPException(status_code=400, detail="Некорректная цель OTP") diff --git a/app/api/public/requests.py b/app/api/public/requests.py index 40518fc..c445f8a 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -1,10 +1,10 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta, timezone from uuid import UUID from uuid import uuid4 -from fastapi import APIRouter, Depends, HTTPException, Response +from fastapi import APIRouter, Depends, HTTPException, Response, Request as FastapiRequest from fastapi.responses import StreamingResponse from sqlalchemy import func from sqlalchemy.orm import Session @@ -28,6 +28,7 @@ from app.models.topic import Topic from app.services.invoice_crypto import decrypt_requisites from app.services.invoice_pdf import build_invoice_pdf_bytes from app.services.chat_secure_service import create_client_message, list_messages_for_request +from app.services.origin_guard import enforce_public_origin_or_403 from app.services.notifications import ( get_client_notification, list_client_notifications, @@ -37,6 +38,7 @@ from app.services.notifications import ( ) from app.services.request_read_markers import clear_unread_for_client from app.services.request_templates import validate_required_topic_fields_or_400 +from app.services.security_audit import extract_client_ip, record_pii_access_event from app.api.admin.requests_modules.status_flow import get_request_status_route_service from app.schemas.public import ( PublicAttachmentRead, @@ -78,6 +80,14 @@ def _normalize_track(raw: str | None) -> str: return str(raw or "").strip().upper() +def _is_honeypot_tripped(raw: str | None) -> bool: + return bool(str(raw or "").strip()) + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + def _set_view_cookie(response: Response, subject: str) -> None: token = create_jwt( {"sub": subject, "purpose": OTP_VIEW_PURPOSE}, @@ -88,12 +98,48 @@ def _set_view_cookie(response: Response, subject: str) -> None: key=settings.PUBLIC_COOKIE_NAME, value=token, httponly=True, - secure=False, - samesite="lax", + secure=settings.public_cookie_secure_effective, + samesite=settings.public_cookie_samesite_effective, max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600, ) +def _public_actor_subject(session: dict) -> str: + subject = _require_view_session_or_403(session) + normalized_track = _normalize_track(subject) + if normalized_track.startswith("TRK-"): + return normalized_track + normalized_phone = _normalize_phone(subject) + if normalized_phone: + return normalized_phone + normalized_email = _normalize_email(subject) + return normalized_email or subject + + +def _record_public_read_audit( + db: Session, + *, + session: dict, + http_request: FastapiRequest, + action: str, + scope: str, + request_id: str | UUID | None = None, + details: dict | None = None, +) -> None: + record_pii_access_event( + db, + actor_role="CLIENT", + actor_subject=_public_actor_subject(session), + actor_ip=extract_client_ip(http_request), + action=action, + scope=scope, + request_id=request_id, + details=details or {}, + responsible="Клиент", + persist_now=True, + ) + + def _require_create_session_or_403(session: dict, client_phone: str, client_email: str | None = None) -> None: purpose = str(session.get("purpose") or "").strip().upper() subject = str(session.get("sub") or "").strip() @@ -211,9 +257,15 @@ def _public_invoice_payload(row: Invoice, track_number: str) -> dict: def create_request( payload: PublicRequestCreate, response: Response, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(request, endpoint="/api/public/requests") + if _is_honeypot_tripped(getattr(payload, "hp_field", None)): + raise HTTPException(status_code=400, detail="Некорректный запрос") + if not bool(payload.pdn_consent): + raise HTTPException(status_code=400, detail="Необходимо согласие на обработку персональных данных") _require_create_session_or_403(session, payload.client_phone, payload.client_email) validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields) client = _upsert_client_by_phone( @@ -233,9 +285,28 @@ def create_request( topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields, + pdn_consent=True, + pdn_consent_at=_now_utc(), + pdn_consent_ip=extract_client_ip(request), responsible="Клиент", ) db.add(row) + db.flush() + db.add( + AuditLog( + actor_admin_id=None, + entity="requests", + entity_id=str(row.id), + action="PDN_CONSENT_CAPTURED", + diff={ + "track_number": row.track_number, + "consent": True, + "consent_at": _to_iso(row.pdn_consent_at), + "consent_ip": row.pdn_consent_ip, + }, + responsible="Клиент", + ) + ) db.commit() db.refresh(row) @@ -256,6 +327,7 @@ def list_public_topics(db: Session = Depends(get_db)): @router.get("/my") def list_my_requests( + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -301,7 +373,7 @@ def list_my_requests( by_event[event_key] = int(by_event.get(event_key, 0)) + event_count bucket["by_event"] = by_event bucket["total"] = int(bucket.get("total", 0)) + event_count - return { + payload = { "rows": [ { "id": str(row.id), @@ -319,11 +391,21 @@ def list_my_requests( ], "total": len(rows), } + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_REQUEST_LIST", + scope="REQUEST_LIST", + details={"total": len(rows)}, + ) + return payload @router.get("/{track_number}") def get_request_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -370,7 +452,7 @@ def get_request_by_track( request_id=req.id, ) - return { + payload = { "id": str(req.id), "client_id": str(req.client_id) if req.client_id else None, "track_number": req.track_number, @@ -397,11 +479,22 @@ def get_request_by_track( "created_at": _to_iso(req.created_at), "updated_at": _to_iso(req.updated_at), } + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_REQUEST_CARD", + scope="REQUEST_CARD", + request_id=req.id, + details={"track_number": req.track_number}, + ) + return payload @router.get("/{track_number}/status-route") def get_status_route_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -413,11 +506,20 @@ def get_status_route_by_track( {"role": "ADMIN", "sub": "", "email": "Клиент"}, ) payload["available_statuses"] = [] + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_REQUEST_STATUS_ROUTE", + scope="REQUEST_STATUS_ROUTE", + request_id=req.id, + details={"track_number": req.track_number}, + ) return payload except Exception: current = str(req.status_code or "").strip() changed_at = _to_iso(req.updated_at or req.created_at) - return { + payload = { "request_id": str(req.id), "track_number": req.track_number, "topic_code": req.topic_code, @@ -450,17 +552,28 @@ def get_status_route_by_track( if current else [], } + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_REQUEST_STATUS_ROUTE", + scope="REQUEST_STATUS_ROUTE", + request_id=req.id, + details={"track_number": req.track_number, "fallback": True}, + ) + return payload @router.get("/{track_number}/messages", response_model=list[PublicMessageRead]) def list_messages_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): req = _request_for_track_or_404(db, session, track_number) rows = list_messages_for_request(db, req.id) - return [ + payload = [ PublicMessageRead( id=row.id, request_id=row.request_id, @@ -472,15 +585,27 @@ def list_messages_by_track( ) for row in rows ] + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_CHAT_MESSAGES", + scope="CHAT", + request_id=req.id, + details={"rows": len(rows)}, + ) + return payload @router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201) def create_message_by_track( track_number: str, payload: PublicMessageCreate, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/messages") req = _request_for_track_or_404(db, session, track_number) row = create_client_message(db, request=req, body=payload.body) @@ -498,6 +623,7 @@ def create_message_by_track( @router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead]) def list_attachments_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -508,7 +634,7 @@ def list_attachments_by_track( .order_by(Attachment.created_at.desc(), Attachment.id.desc()) .all() ) - return [ + payload = [ PublicAttachmentRead( id=row.id, request_id=row.request_id, @@ -521,11 +647,22 @@ def list_attachments_by_track( ) for row in rows ] + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_ATTACHMENTS", + scope="REQUEST_ATTACHMENTS", + request_id=req.id, + details={"rows": len(rows)}, + ) + return payload @router.get("/{track_number}/invoices") def list_invoices_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -536,13 +673,24 @@ def list_invoices_by_track( .order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc()) .all() ) - return [_public_invoice_payload(row, req.track_number) for row in rows] + payload = [_public_invoice_payload(row, req.track_number) for row in rows] + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_INVOICE_LIST", + scope="INVOICE", + request_id=req.id, + details={"rows": len(rows)}, + ) + return payload @router.get("/{track_number}/invoices/{invoice_id}/pdf") def download_invoice_pdf_by_track( track_number: str, invoice_id: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -570,6 +718,15 @@ def download_invoice_pdf_by_track( issued_by_name=(issuer.name if issuer else invoice.issued_by_role), requisites=requisites, ) + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_INVOICE_PDF", + scope="INVOICE", + request_id=req.id, + details={"invoice_id": str(invoice.id), "invoice_number": invoice.invoice_number}, + ) file_name = f"{invoice.invoice_number}.pdf" headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers) @@ -578,6 +735,7 @@ def download_invoice_pdf_by_track( @router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead]) def list_status_history_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -588,7 +746,7 @@ def list_status_history_by_track( .order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc()) .all() ) - return [ + payload = [ PublicStatusHistoryRead( id=row.id, request_id=row.request_id, @@ -599,11 +757,22 @@ def list_status_history_by_track( ) for row in rows ] + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_REQUEST_HISTORY", + scope="REQUEST_HISTORY", + request_id=req.id, + details={"rows": len(rows)}, + ) + return payload @router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent]) def list_timeline_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -658,6 +827,15 @@ def list_timeline_by_track( return event.created_at or "" events.sort(key=_sort_key) + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_REQUEST_TIMELINE", + scope="REQUEST_TIMELINE", + request_id=req.id, + details={"rows": len(events)}, + ) return events @@ -665,9 +843,11 @@ def list_timeline_by_track( def create_service_request_by_track( track_number: str, payload: PublicServiceRequestCreate, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/service-requests") req = _request_for_track_or_404(db, session, track_number) request_type = str(payload.type or "").strip().upper() if request_type not in SERVICE_REQUEST_TYPES: @@ -720,6 +900,7 @@ def create_service_request_by_track( @router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead]) def list_service_requests_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): @@ -733,12 +914,23 @@ def list_service_requests_by_track( .order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc()) .all() ) - return [_serialize_public_service_request(row) for row in rows] + payload = [_serialize_public_service_request(row) for row in rows] + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_SERVICE_REQUESTS", + scope="SERVICE_REQUEST", + request_id=req.id, + details={"rows": len(rows)}, + ) + return payload @router.get("/{track_number}/notifications") def list_notifications_by_track( track_number: str, + request: FastapiRequest, unread_only: bool = False, limit: int = 50, offset: int = 0, @@ -762,20 +954,32 @@ def list_notifications_by_track( limit=1, offset=0, ) - return { + payload = { "rows": [serialize_notification(row) for row in rows], "total": int(total), "unread_total": int(unread_total), } + _record_public_read_audit( + db, + session=session, + http_request=request, + action="READ_NOTIFICATIONS", + scope="NOTIFICATION", + request_id=req.id, + details={"rows": int(total), "unread_only": bool(unread_only)}, + ) + return payload @router.post("/{track_number}/notifications/{notification_id}/read") def read_notification_by_track( track_number: str, notification_id: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/notifications/{notification_id}/read") req = _request_for_track_or_404(db, session, track_number) try: notification_uuid = UUID(str(notification_id)) @@ -799,9 +1003,11 @@ def read_notification_by_track( @router.post("/{track_number}/notifications/read-all") def read_all_notifications_by_track( track_number: str, + request: FastapiRequest, db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(request, endpoint="/api/public/requests/{track_number}/notifications/read-all") req = _request_for_track_or_404(db, session, track_number) changed = mark_client_notifications_read( db, diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index 7dbe485..a10eb1b 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -25,6 +25,7 @@ from app.services.attachment_scan import ( initial_scan_status_for_new_attachment, ) from app.services.s3_storage import build_object_key, get_s3_storage +from app.services.origin_guard import enforce_public_origin_or_403 router = APIRouter() @@ -105,6 +106,7 @@ def upload_init( db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(http_request, endpoint="/api/public/uploads/init") actor_subject = str(session.get("sub") or "").strip() actor_ip = _client_ip(http_request) scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) @@ -169,6 +171,7 @@ def upload_complete( db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): + enforce_public_origin_or_403(http_request, endpoint="/api/public/uploads/complete") actor_subject = str(session.get("sub") or "").strip() actor_ip = _client_ip(http_request) scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) diff --git a/app/chat_main.py b/app/chat_main.py index a2021f5..85eb7f1 100644 --- a/app/chat_main.py +++ b/app/chat_main.py @@ -4,16 +4,16 @@ from fastapi.responses import JSONResponse from app.api.admin.chat import router as admin_chat_router from app.api.public.chat import router as public_chat_router -from app.core.config import settings +from app.core.config import settings, validate_production_security_or_raise from app.core.http_hardening import install_http_hardening app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=settings.cors_allow_methods_list, + allow_headers=settings.cors_allow_headers_list, ) install_http_hardening(app) @@ -21,6 +21,11 @@ app.include_router(public_chat_router, prefix="/api/public/chat") app.include_router(admin_chat_router, prefix="/api/admin/chat") +@app.on_event("startup") +def _validate_security_config_on_startup() -> None: + validate_production_security_or_raise("chat-service") + + @app.get("/", include_in_schema=False) def landing(): return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"}) diff --git a/app/core/config.py b/app/core/config.py index 45e8977..1ebc2ab 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -18,8 +18,20 @@ class Settings(BaseSettings): TOTP_ISSUER: str = "Правовой Трекер" PUBLIC_JWT_SECRET: str = "change_me_public" PUBLIC_COOKIE_NAME: str = "public_jwt" + PUBLIC_COOKIE_SECURE: bool = False + PUBLIC_COOKIE_SAMESITE: str = "lax" + PUBLIC_STRICT_ORIGIN_CHECK: bool = True + PUBLIC_ALLOWED_WEB_ORIGINS: str = ( + "http://localhost:8080,http://localhost:8081," + "https://ruakb.ru,https://www.ruakb.ru," + "https://ruakb.online,https://www.ruakb.online" + ) + PRODUCTION_ENFORCE_SECURE_SETTINGS: bool = True CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081" + CORS_ALLOW_METHODS: str = "GET,POST,PUT,PATCH,DELETE,OPTIONS" + CORS_ALLOW_HEADERS: str = "Authorization,Content-Type,X-Requested-With,X-Request-ID" + CORS_ALLOW_CREDENTIALS: bool = True DATABASE_URL: str REDIS_URL: str @@ -30,6 +42,8 @@ class Settings(BaseSettings): S3_BUCKET: str S3_REGION: str = "us-east-1" S3_USE_SSL: bool = False + S3_VERIFY_SSL: bool = True + S3_CA_CERT_PATH: str = "" MAX_FILE_MB: int = 25 MAX_CASE_MB: int = 250 ATTACHMENT_SCAN_ENABLED: bool = False @@ -64,6 +78,10 @@ class Settings(BaseSettings): OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}" OTP_EMAIL_FALLBACK_ENABLED: bool = True OTP_SMS_MIN_BALANCE: float = 20.0 + DATA_ENCRYPTION_ACTIVE_KID: str = "legacy" + DATA_ENCRYPTION_KEYS: str = "" + CHAT_ENCRYPTION_ACTIVE_KID: str = "" + CHAT_ENCRYPTION_KEYS: str = "" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" CHAT_ENCRYPTION_SECRET: str = "" OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 @@ -79,9 +97,154 @@ class Settings(BaseSettings): POSTGRES_USER: str = "postgres" POSTGRES_PASSWORD: str = "postgres" POSTGRES_DB: str = "legal" + MINIO_ROOT_USER: str = "minio_local_admin" + MINIO_ROOT_PASSWORD: str = "minio_local_password_change_me" + MINIO_TLS_ENABLED: bool = False @property def cors_origins_list(self) -> List[str]: return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] + @property + def cors_allow_methods_list(self) -> List[str]: + values = [v.strip().upper() for v in str(self.CORS_ALLOW_METHODS or "").split(",") if v.strip()] + return values or ["GET", "POST", "OPTIONS"] + + @property + def cors_allow_headers_list(self) -> List[str]: + values = [v.strip() for v in str(self.CORS_ALLOW_HEADERS or "").split(",") if v.strip()] + return values or ["Authorization", "Content-Type"] + + @property + def public_allowed_web_origins_list(self) -> List[str]: + values: list[str] = [] + for item in str(self.PUBLIC_ALLOWED_WEB_ORIGINS or "").split(","): + value = item.strip().rstrip("/").lower() + if value: + values.append(value) + return values + + @property + def app_env_is_production(self) -> bool: + return str(self.APP_ENV or "").strip().lower() in {"prod", "production"} + + @property + def public_cookie_secure_effective(self) -> bool: + if self.app_env_is_production: + return True + return bool(self.PUBLIC_COOKIE_SECURE) + + @property + def public_cookie_samesite_effective(self) -> str: + raw = str(self.PUBLIC_COOKIE_SAMESITE or "lax").strip().lower() + if raw in {"lax", "strict", "none"}: + return raw + return "lax" + settings = Settings() + + +def _looks_insecure_secret(value: str, *, min_len: int = 16) -> bool: + raw = str(value or "").strip() + lowered = raw.lower() + if len(raw) < min_len: + return True + markers = ( + "change_me", + "example", + "admin123", + "password", + "test", + "local", + ) + return any(marker in lowered for marker in markers) + + +def validate_production_security_or_raise(component: str = "app") -> None: + if not settings.app_env_is_production: + return + if not bool(getattr(settings, "PRODUCTION_ENFORCE_SECURE_SETTINGS", True)): + return + + issues: list[str] = [] + + if bool(settings.OTP_DEV_MODE): + issues.append("OTP_DEV_MODE=true запрещен в production") + if bool(settings.ADMIN_BOOTSTRAP_ENABLED): + issues.append("ADMIN_BOOTSTRAP_ENABLED=true запрещен в production") + + if not settings.public_cookie_secure_effective: + issues.append("PUBLIC cookie должен быть secure в production") + + if settings.public_cookie_samesite_effective == "none" and not settings.public_cookie_secure_effective: + issues.append("PUBLIC_COOKIE_SAMESITE=none требует secure cookie") + + if _looks_insecure_secret(settings.ADMIN_JWT_SECRET): + issues.append("ADMIN_JWT_SECRET выглядит небезопасным") + if _looks_insecure_secret(settings.PUBLIC_JWT_SECRET): + issues.append("PUBLIC_JWT_SECRET выглядит небезопасным") + if _looks_insecure_secret(settings.DATA_ENCRYPTION_SECRET): + issues.append("DATA_ENCRYPTION_SECRET выглядит небезопасным") + if _looks_insecure_secret(settings.INTERNAL_SERVICE_TOKEN): + issues.append("INTERNAL_SERVICE_TOKEN выглядит небезопасным") + + if not str(settings.CHAT_ENCRYPTION_SECRET or "").strip(): + # Backward-compatible: keyring-based CHAT_ENCRYPTION_KEYS is allowed. + if not str(getattr(settings, "CHAT_ENCRYPTION_KEYS", "") or "").strip(): + issues.append("CHAT_ENCRYPTION_SECRET или CHAT_ENCRYPTION_KEYS обязателен в production") + + if not str(getattr(settings, "DATA_ENCRYPTION_ACTIVE_KID", "") or "").strip(): + issues.append("DATA_ENCRYPTION_ACTIVE_KID должен быть задан в production") + + minio_user = str(settings.MINIO_ROOT_USER or "").strip().lower() + minio_password = str(settings.MINIO_ROOT_PASSWORD or "").strip() + if minio_user in {"", "minioadmin", "minio_local_admin"}: + issues.append("MINIO_ROOT_USER должен быть переопределен для production") + if _looks_insecure_secret(minio_password): + issues.append("MINIO_ROOT_PASSWORD выглядит небезопасным") + + if not bool(settings.S3_USE_SSL): + issues.append("S3_USE_SSL должен быть включен в production") + s3_endpoint = str(settings.S3_ENDPOINT or "").strip().lower() + if not s3_endpoint.startswith("https://"): + issues.append("S3_ENDPOINT должен начинаться с https:// в production") + if not bool(settings.S3_VERIFY_SSL): + issues.append("S3_VERIFY_SSL должен быть включен в production") + if not str(settings.S3_CA_CERT_PATH or "").strip(): + issues.append("S3_CA_CERT_PATH должен быть задан для trusted TLS в production") + if not bool(settings.MINIO_TLS_ENABLED): + issues.append("MINIO_TLS_ENABLED должен быть включен в production") + + if bool(getattr(settings, "PUBLIC_STRICT_ORIGIN_CHECK", True)): + allowed_public_origins = settings.public_allowed_web_origins_list + if not allowed_public_origins: + issues.append("PUBLIC_ALLOWED_WEB_ORIGINS должен быть задан в production") + for origin in allowed_public_origins: + if "localhost" in origin or "127.0.0.1" in origin: + issues.append("PUBLIC_ALLOWED_WEB_ORIGINS не должен содержать localhost в production") + break + + cors_origins = [item.strip().lower().rstrip("/") for item in settings.cors_origins_list] + if not cors_origins: + issues.append("CORS_ORIGINS должен быть задан в production") + for origin in cors_origins: + if origin == "*" or "*" in origin: + issues.append("CORS_ORIGINS не должен содержать wildcard (*) в production") + break + if "localhost" in origin or "127.0.0.1" in origin: + issues.append("CORS_ORIGINS не должен содержать localhost в production") + break + if not origin.startswith("https://"): + issues.append("CORS_ORIGINS должен содержать только https origins в production") + break + + cors_methods = [item.strip().upper() for item in settings.cors_allow_methods_list] + if "*" in cors_methods: + issues.append("CORS_ALLOW_METHODS не должен содержать wildcard (*) в production") + cors_headers_lower = [item.strip().lower() for item in settings.cors_allow_headers_list] + if "*" in cors_headers_lower: + issues.append("CORS_ALLOW_HEADERS не должен содержать wildcard (*) в production") + + if issues: + formatted = "\n".join(f"- {item}" for item in issues) + raise RuntimeError(f"[{component}] insecure production configuration:\n{formatted}") diff --git a/app/core/http_hardening.py b/app/core/http_hardening.py index 4a82cf0..129ac57 100644 --- a/app/core/http_hardening.py +++ b/app/core/http_hardening.py @@ -20,13 +20,35 @@ SECURITY_HEADERS = { "Cross-Origin-Embedder-Policy": "credentialless", "Cross-Origin-Resource-Policy": "same-origin", "Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()", - "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + "Content-Security-Policy": ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "connect-src 'self'; " + "img-src 'self' data: blob:; " + "font-src 'self' data:; " + "object-src 'none'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ), } FRAMEABLE_FILE_SECURITY_HEADERS = { **SECURITY_HEADERS, "X-Frame-Options": "SAMEORIGIN", - "Content-Security-Policy": "default-src 'self'; object-src 'none'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'", + "Content-Security-Policy": ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "connect-src 'self'; " + "img-src 'self' data: blob:; " + "font-src 'self' data:; " + "object-src 'none'; " + "frame-ancestors 'self'; " + "base-uri 'self'; " + "form-action 'self'" + ), } _FRAMEABLE_PATH_PATTERNS = ( diff --git a/app/email_main.py b/app/email_main.py index ebd79be..b955a98 100644 --- a/app/email_main.py +++ b/app/email_main.py @@ -3,7 +3,7 @@ from __future__ import annotations from fastapi import FastAPI, Header, HTTPException from pydantic import BaseModel -from app.core.config import settings +from app.core.config import settings, validate_production_security_or_raise from app.services.email_service import EmailDeliveryError, send_email_via_smtp app = FastAPI(title="law-email-service") @@ -15,6 +15,11 @@ class InternalEmailSend(BaseModel): body: str +@app.on_event("startup") +def _validate_security_config_on_startup() -> None: + validate_production_security_or_raise("email-service") + + @app.get("/health") def health(): return {"status": "ok", "service": "email-service"} diff --git a/app/main.py b/app/main.py index d1fc58e..0631ac3 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from app.core.config import settings +from app.core.config import settings, validate_production_security_or_raise from app.core.http_hardening import install_http_hardening from app.api.public.router import router as public_router from app.api.admin.router import router as admin_router @@ -10,15 +10,20 @@ app = FastAPI(title=settings.APP_NAME, version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=settings.cors_allow_methods_list, + allow_headers=settings.cors_allow_headers_list, ) install_http_hardening(app) app.include_router(public_router, prefix="/api/public") app.include_router(admin_router, prefix="/api/admin") + +@app.on_event("startup") +def _validate_security_config_on_startup() -> None: + validate_production_security_or_raise("backend") + @app.get("/", include_in_schema=False) def landing(): return JSONResponse({"service": settings.APP_NAME, "status": "ok"}) diff --git a/app/models/data_retention_policy.py b/app/models/data_retention_policy.py new file mode 100644 index 0000000..c73cfa1 --- /dev/null +++ b/app/models/data_retention_policy.py @@ -0,0 +1,15 @@ +from sqlalchemy import Boolean, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base +from app.models.common import TimestampMixin, UUIDMixin + + +class DataRetentionPolicy(Base, UUIDMixin, TimestampMixin): + __tablename__ = "data_retention_policies" + + entity: Mapped[str] = mapped_column(String(80), unique=True, nullable=False, index=True) + retention_days: Mapped[int] = mapped_column(Integer, nullable=False, default=365) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + hard_delete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + description: Mapped[str | None] = mapped_column(String(300), nullable=True) diff --git a/app/models/request.py b/app/models/request.py index 8d6cdac..308bcb7 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -14,6 +14,9 @@ class Request(Base, UUIDMixin, TimestampMixin): client_name: Mapped[str] = mapped_column(String(200), nullable=False) client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) client_email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + pdn_consent: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + pdn_consent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + pdn_consent_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW") important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) diff --git a/app/schemas/public.py b/app/schemas/public.py index a42608d..0137309 100644 --- a/app/schemas/public.py +++ b/app/schemas/public.py @@ -6,6 +6,8 @@ class PublicRequestCreate(BaseModel): client_name: str client_phone: str client_email: Optional[str] = None + pdn_consent: bool = False + hp_field: Optional[str] = None topic_code: Optional[str] = None description: Optional[str] = None extra_fields: Dict[str, Any] = Field(default_factory=dict) @@ -21,6 +23,7 @@ class OtpSend(BaseModel): track_number: Optional[str] = None client_phone: Optional[str] = None client_email: Optional[str] = None + hp_field: Optional[str] = None channel: Optional[str] = None class OtpVerify(BaseModel): diff --git a/app/scripts/reencrypt_with_active_kid.py b/app/scripts/reencrypt_with_active_kid.py new file mode 100644 index 0000000..6c72e6c --- /dev/null +++ b/app/scripts/reencrypt_with_active_kid.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import argparse +from datetime import datetime, timezone + +from sqlalchemy import text + +from app.db.session import SessionLocal +from app.models.admin_user import AdminUser +from app.models.invoice import Invoice +from app.services.chat_crypto import active_chat_kid, decrypt_message_body, encrypt_message_body, extract_message_kid, is_encrypted_message +from app.services.invoice_crypto import active_requisites_kid, decrypt_requisites, encrypt_requisites, extract_requisites_kid + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def reencrypt_with_active_kid(*, dry_run: bool = True) -> dict[str, int]: + db = SessionLocal() + counts = { + "invoices_total": 0, + "invoices_reencrypted": 0, + "admin_totp_total": 0, + "admin_totp_reencrypted": 0, + "messages_total": 0, + "messages_reencrypted": 0, + "errors": 0, + } + current_data_kid = active_requisites_kid() + current_chat_kid = active_chat_kid() + + try: + invoice_rows = db.query(Invoice).all() + counts["invoices_total"] = len(invoice_rows) + for row in invoice_rows: + token = str(row.payer_details_encrypted or "").strip() + if not token: + continue + if extract_requisites_kid(token) == current_data_kid: + continue + try: + payload = decrypt_requisites(token) + row.payer_details_encrypted = encrypt_requisites(payload) + row.responsible = row.responsible or "Администратор системы" + counts["invoices_reencrypted"] += 1 + except Exception: + counts["errors"] += 1 + + admin_rows = db.query(AdminUser).all() + counts["admin_totp_total"] = len(admin_rows) + for row in admin_rows: + token = str(row.totp_secret_encrypted or "").strip() + if not token: + continue + if extract_requisites_kid(token) == current_data_kid: + continue + try: + payload = decrypt_requisites(token) + row.totp_secret_encrypted = encrypt_requisites(payload) + row.responsible = row.responsible or "Администратор системы" + counts["admin_totp_reencrypted"] += 1 + except Exception: + counts["errors"] += 1 + + message_rows = db.execute(text("SELECT id, body FROM messages")).all() + counts["messages_total"] = len(message_rows) + for message_id, body in message_rows: + raw_body = str(body or "") + if not raw_body: + continue + if extract_message_kid(raw_body) == current_chat_kid: + continue + try: + plaintext = decrypt_message_body(raw_body) + updated = encrypt_message_body(plaintext) + if updated == raw_body: + continue + db.execute( + text("UPDATE messages SET body = :body, updated_at = :updated_at WHERE id = :id"), + {"id": message_id, "body": updated, "updated_at": _now_utc()}, + ) + counts["messages_reencrypted"] += 1 + except Exception: + counts["errors"] += 1 + + if dry_run: + db.rollback() + else: + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + + return counts + + +def main() -> None: + parser = argparse.ArgumentParser(description="Re-encrypt sensitive fields using active KID keys") + parser.add_argument("--apply", action="store_true", help="Apply changes (default is dry-run)") + args = parser.parse_args() + + result = reencrypt_with_active_kid(dry_run=not args.apply) + mode = "APPLY" if args.apply else "DRY_RUN" + print(f"mode={mode}") + for key in sorted(result.keys()): + print(f"{key}={result[key]}") + + +if __name__ == "__main__": + main() diff --git a/app/services/chat_crypto.py b/app/services/chat_crypto.py index dd307d9..cfbbee1 100644 --- a/app/services/chat_crypto.py +++ b/app/services/chat_crypto.py @@ -5,39 +5,42 @@ import hashlib import hmac import secrets -from app.core.config import settings +from app.services.crypto_keyring import get_chat_secrets, key_digest, ordered_unique_key_digests -_VERSION = b"v1" -_PREFIX = "chatenc:v1:" - - -def _encryption_secret() -> str: - chat_secret = str(settings.CHAT_ENCRYPTION_SECRET or "").strip() - if chat_secret: - return chat_secret - fallback = str(settings.DATA_ENCRYPTION_SECRET or "").strip() - if fallback: - return fallback - fallback = str(settings.ADMIN_JWT_SECRET or "").strip() - if fallback: - return fallback - fallback = str(settings.PUBLIC_JWT_SECRET or "").strip() - if fallback: - return fallback - raise ValueError("Не задан секрет шифрования чата") - - -def _key() -> bytes: - return hashlib.sha256(_encryption_secret().encode("utf-8")).digest() +_VERSION_LEGACY = b"v1" +_PREFIX_LEGACY = "chatenc:v1:" +_PREFIX_V2 = "chatenc:v2:" def _xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) +def _aad_v2(kid: str) -> bytes: + return b"v2|" + str(kid).encode("utf-8") + b"|" + + +def active_chat_kid() -> str: + active_kid, _ = get_chat_secrets() + return active_kid + + +def extract_message_kid(value: str | None) -> str | None: + token = str(value or "").strip() + if not token: + return None + if token.startswith(_PREFIX_V2): + parts = token.split(":", 3) + if len(parts) != 4: + return None + kid = str(parts[2] or "").strip() + return kid or None + return None + + def is_encrypted_message(value: str | None) -> bool: token = str(value or "").strip() - return token.startswith(_PREFIX) + return token.startswith(_PREFIX_LEGACY) or token.startswith(_PREFIX_V2) def encrypt_message_body(value: str | None) -> str | None: @@ -48,13 +51,57 @@ def encrypt_message_body(value: str | None) -> str | None: return text if is_encrypted_message(text): return text + + active_kid, key_map = get_chat_secrets() + active_secret = key_map.get(active_kid) + if not active_secret: + raise ValueError("Не найден активный ключ шифрования чата") + key = key_digest(active_secret) + raw = text.encode("utf-8") nonce = secrets.token_bytes(16) - stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw)) + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw)) cipher = _xor_bytes(raw, stream) - tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest() - token = _VERSION + nonce + tag + cipher - return _PREFIX + base64.urlsafe_b64encode(token).decode("ascii") + tag = hmac.new(key, _aad_v2(active_kid) + nonce + cipher, hashlib.sha256).digest() + blob = nonce + tag + cipher + return f"{_PREFIX_V2}{active_kid}:" + base64.urlsafe_b64encode(blob).decode("ascii") + + +def _decrypt_v2(encoded: str, *, kid: str, key: bytes) -> str: + blob = base64.urlsafe_b64decode(encoded.encode("ascii")) + if len(blob) < 16 + 32: + raise ValueError("Некорректный зашифрованный формат сообщения") + nonce = blob[:16] + tag = blob[16:48] + cipher = blob[48:] + expected = hmac.new(key, _aad_v2(kid) + nonce + cipher, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + raise ValueError("Поврежденные данные сообщения") + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher)) + raw = _xor_bytes(cipher, stream) + return raw.decode("utf-8") + + +def _decrypt_legacy(encoded: str, keys: list[bytes]) -> str: + blob = base64.urlsafe_b64decode(encoded.encode("ascii")) + if len(blob) < 2 + 16 + 32: + raise ValueError("Некорректный зашифрованный формат сообщения") + version = blob[:2] + nonce = blob[2:18] + tag = blob[18:50] + cipher = blob[50:] + if version != _VERSION_LEGACY: + raise ValueError("Неподдерживаемая версия шифрования чата") + + for key in keys: + expected = hmac.new(key, version + nonce + cipher, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + continue + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher)) + raw = _xor_bytes(cipher, stream) + return raw.decode("utf-8") + + raise ValueError("Поврежденные данные сообщения") def decrypt_message_body(value: str | None) -> str | None: @@ -65,19 +112,23 @@ def decrypt_message_body(value: str | None) -> str | None: return text if not is_encrypted_message(text): return text - encoded = text[len(_PREFIX) :] - blob = base64.urlsafe_b64decode(encoded.encode("ascii")) - if len(blob) < 2 + 16 + 32: - raise ValueError("Некорректный зашифрованный формат сообщения") - version = blob[:2] - nonce = blob[2:18] - tag = blob[18:50] - cipher = blob[50:] - if version != _VERSION: - raise ValueError("Неподдерживаемая версия шифрования чата") - expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest() - if not hmac.compare_digest(tag, expected): - raise ValueError("Поврежденные данные сообщения") - stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher)) - raw = _xor_bytes(cipher, stream) - return raw.decode("utf-8") + + active_kid, key_map = get_chat_secrets() + _ = active_kid + if text.startswith(_PREFIX_V2): + encoded = text[len(_PREFIX_V2) :] + parts = encoded.split(":", 1) + if len(parts) != 2: + raise ValueError("Некорректный зашифрованный формат сообщения") + kid, payload = str(parts[0] or "").strip(), parts[1] + if kid in key_map: + return _decrypt_v2(payload, kid=kid, key=key_digest(key_map[kid])) + for fallback_key in ordered_unique_key_digests(key_map.values()): + try: + return _decrypt_v2(payload, kid=kid, key=fallback_key) + except Exception: + continue + raise ValueError("Неподдерживаемый идентификатор ключа шифрования") + + encoded = text[len(_PREFIX_LEGACY) :] + return _decrypt_legacy(encoded, ordered_unique_key_digests(key_map.values())) diff --git a/app/services/crypto_keyring.py b/app/services/crypto_keyring.py new file mode 100644 index 0000000..b31924a --- /dev/null +++ b/app/services/crypto_keyring.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import hashlib +from typing import Iterable + +from app.core.config import settings + + +def _normalize_kid(raw: str | None) -> str: + value = str(raw or "").strip().lower() + if not value: + return "" + out = [] + for ch in value: + if ch.isalnum() or ch in {"_", "-", "."}: + out.append(ch) + return "".join(out)[:64] + + +def _parse_kid_secret_map(raw: str | None) -> dict[str, str]: + values: dict[str, str] = {} + for chunk in str(raw or "").split(","): + token = str(chunk or "").strip() + if not token: + continue + if "=" not in token: + continue + kid_raw, secret_raw = token.split("=", 1) + kid = _normalize_kid(kid_raw) + secret = str(secret_raw or "").strip() + if not kid or not secret: + continue + values[kid] = secret + return values + + +def _primary_data_secret() -> str: + for candidate in ( + str(settings.DATA_ENCRYPTION_SECRET or "").strip(), + str(settings.ADMIN_JWT_SECRET or "").strip(), + str(settings.PUBLIC_JWT_SECRET or "").strip(), + ): + if candidate: + return candidate + return "" + + +def get_data_secrets() -> tuple[str, dict[str, str]]: + key_map = _parse_kid_secret_map(getattr(settings, "DATA_ENCRYPTION_KEYS", "")) + legacy = _primary_data_secret() + if legacy: + key_map.setdefault("legacy", legacy) + + active_raw = _normalize_kid(getattr(settings, "DATA_ENCRYPTION_ACTIVE_KID", "")) + active_kid = active_raw or ("legacy" if "legacy" in key_map else "") + + if not key_map and legacy: + active_kid = active_kid or "legacy" + key_map[active_kid] = legacy + + if active_kid and active_kid not in key_map and legacy: + key_map[active_kid] = legacy + + if not key_map: + raise ValueError("Не заданы ключи шифрования DATA") + + if not active_kid: + active_kid = next(iter(key_map.keys())) + + return active_kid, key_map + + +def get_chat_secrets() -> tuple[str, dict[str, str]]: + key_map = _parse_kid_secret_map(getattr(settings, "CHAT_ENCRYPTION_KEYS", "")) + chat_legacy = str(settings.CHAT_ENCRYPTION_SECRET or "").strip() + if chat_legacy: + key_map.setdefault("legacy", chat_legacy) + + data_active, data_map = get_data_secrets() + for kid, secret in data_map.items(): + key_map.setdefault(kid, secret) + + active_raw = _normalize_kid(getattr(settings, "CHAT_ENCRYPTION_ACTIVE_KID", "")) + active_kid = active_raw or data_active + + if active_kid and active_kid not in key_map and chat_legacy: + key_map[active_kid] = chat_legacy + + if active_kid and active_kid not in key_map and active_kid in data_map: + key_map[active_kid] = data_map[active_kid] + + if not key_map: + raise ValueError("Не заданы ключи шифрования CHAT") + + if not active_kid: + active_kid = next(iter(key_map.keys())) + + if active_kid not in key_map: + key_map[active_kid] = next(iter(key_map.values())) + + return active_kid, key_map + + +def key_digest(secret: str) -> bytes: + return hashlib.sha256(str(secret or "").encode("utf-8")).digest() + + +def ordered_unique_key_digests(secrets: Iterable[str]) -> list[bytes]: + digests: list[bytes] = [] + seen: set[bytes] = set() + for secret in secrets: + digest = key_digest(secret) + if digest in seen: + continue + seen.add(digest) + digests.append(digest) + return digests diff --git a/app/services/invoice_crypto.py b/app/services/invoice_crypto.py index 2f05801..25a81bc 100644 --- a/app/services/invoice_crypto.py +++ b/app/services/invoice_crypto.py @@ -7,54 +7,120 @@ import json import secrets from typing import Any -from app.core.config import settings +from app.services.crypto_keyring import get_data_secrets, key_digest, ordered_unique_key_digests -_VERSION = b"v1" - - -def _key() -> bytes: - secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip() - if not secret: - secret = str(settings.ADMIN_JWT_SECRET or "").strip() - if not secret: - secret = str(settings.PUBLIC_JWT_SECRET or "").strip() - if not secret: - raise ValueError("Не задан секрет шифрования") - return hashlib.sha256(secret.encode("utf-8")).digest() +_VERSION_LEGACY = b"v1" +_PREFIX_V2 = "invenc:v2:" def _xor_bytes(a: bytes, b: bytes) -> bytes: return bytes(x ^ y for x, y in zip(a, b)) -def encrypt_requisites(data: dict[str, Any] | None) -> str: - payload = dict(data or {}) - raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - nonce = secrets.token_bytes(16) - stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(raw)) - cipher = _xor_bytes(raw, stream) - tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest() - token = _VERSION + nonce + tag + cipher - return base64.urlsafe_b64encode(token).decode("ascii") +def _aad_v2(kid: str) -> bytes: + return b"v2|" + str(kid).encode("utf-8") + b"|" -def decrypt_requisites(token: str | None) -> dict[str, Any]: +def active_requisites_kid() -> str: + active_kid, _ = get_data_secrets() + return active_kid + + +def extract_requisites_kid(token: str | None) -> str | None: encoded = str(token or "").strip() if not encoded: - return {} + return None + if not encoded.startswith(_PREFIX_V2): + return None + parts = encoded.split(":", 3) + if len(parts) != 4: + return None + kid = str(parts[2] or "").strip() + return kid or None + + +def _encrypt_payload(raw: bytes, *, kid: str, key: bytes) -> str: + nonce = secrets.token_bytes(16) + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw)) + cipher = _xor_bytes(raw, stream) + tag = hmac.new(key, _aad_v2(kid) + nonce + cipher, hashlib.sha256).digest() + token = nonce + tag + cipher + encoded = base64.urlsafe_b64encode(token).decode("ascii") + return f"{_PREFIX_V2}{kid}:{encoded}" + + +def _decrypt_v2(encoded: str, *, kid: str, key: bytes) -> dict[str, Any]: blob = base64.urlsafe_b64decode(encoded.encode("ascii")) + if len(blob) < 16 + 32: + raise ValueError("Некорректные зашифрованные реквизиты") + nonce = blob[:16] + tag = blob[16:48] + cipher = blob[48:] + expected = hmac.new(key, _aad_v2(kid) + nonce + cipher, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + raise ValueError("Поврежденные зашифрованные реквизиты") + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher)) + raw = _xor_bytes(cipher, stream) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else {} + + +def _decrypt_legacy(token: str, keys: list[bytes]) -> dict[str, Any]: + blob = base64.urlsafe_b64decode(token.encode("ascii")) if len(blob) < 2 + 16 + 32: raise ValueError("Некорректные зашифрованные реквизиты") version = blob[:2] nonce = blob[2:18] tag = blob[18:50] cipher = blob[50:] - if version != _VERSION: + if version != _VERSION_LEGACY: raise ValueError("Неподдерживаемая версия шифрования") - expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest() - if not hmac.compare_digest(tag, expected): - raise ValueError("Поврежденные зашифрованные реквизиты") - stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher)) - raw = _xor_bytes(cipher, stream) - data = json.loads(raw.decode("utf-8")) - return data if isinstance(data, dict) else {} + + for key in keys: + expected = hmac.new(key, version + nonce + cipher, hashlib.sha256).digest() + if not hmac.compare_digest(tag, expected): + continue + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher)) + raw = _xor_bytes(cipher, stream) + data = json.loads(raw.decode("utf-8")) + return data if isinstance(data, dict) else {} + + raise ValueError("Поврежденные зашифрованные реквизиты") + + +def encrypt_requisites(data: dict[str, Any] | None) -> str: + payload = dict(data or {}) + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + active_kid, key_map = get_data_secrets() + active_secret = key_map.get(active_kid) + if not active_secret: + raise ValueError("Не найден активный ключ шифрования DATA") + return _encrypt_payload(raw, kid=active_kid, key=key_digest(active_secret)) + + +def decrypt_requisites(token: str | None) -> dict[str, Any]: + encoded = str(token or "").strip() + if not encoded: + return {} + + active_kid, key_map = get_data_secrets() + _ = active_kid + if encoded.startswith(_PREFIX_V2): + parts = encoded.split(":", 3) + if len(parts) != 4: + raise ValueError("Некорректные зашифрованные реквизиты") + kid = str(parts[2] or "").strip() + payload = parts[3] + + if kid in key_map: + return _decrypt_v2(payload, kid=kid, key=key_digest(key_map[kid])) + + for fallback_key in ordered_unique_key_digests(key_map.values()): + try: + return _decrypt_v2(payload, kid=kid, key=fallback_key) + except Exception: + continue + raise ValueError("Неподдерживаемый идентификатор ключа шифрования") + + return _decrypt_legacy(encoded, ordered_unique_key_digests(key_map.values())) diff --git a/app/services/origin_guard.py b/app/services/origin_guard.py new file mode 100644 index 0000000..95097a9 --- /dev/null +++ b/app/services/origin_guard.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from urllib.parse import urlsplit + +from fastapi import HTTPException, Request + +from app.core.config import settings + + +def _origin_from_header(value: str | None) -> str: + raw = str(value or "").strip() + if not raw: + return "" + parts = urlsplit(raw) + if not parts.scheme or not parts.netloc: + return "" + return f"{parts.scheme.lower()}://{parts.netloc.lower()}".rstrip("/") + + +def _sec_fetch_site(value: str | None) -> str: + return str(value or "").strip().lower() + + +def enforce_public_origin_or_403(request: Request, *, endpoint: str) -> None: + if not bool(getattr(settings, "PUBLIC_STRICT_ORIGIN_CHECK", True)): + return + if not bool(getattr(settings, "app_env_is_production", False)): + return + + fetch_site = _sec_fetch_site(request.headers.get("sec-fetch-site")) + if fetch_site == "cross-site": + raise HTTPException(status_code=403, detail=f"Forbidden origin for {endpoint}") + + allowed = set(settings.public_allowed_web_origins_list) + if not allowed: + raise HTTPException(status_code=500, detail="Не настроен список разрешенных public-origin") + + origin = _origin_from_header(request.headers.get("origin")) + if not origin: + origin = _origin_from_header(request.headers.get("referer")) + if not origin: + raise HTTPException(status_code=403, detail=f"Forbidden origin for {endpoint}") + if origin not in allowed: + raise HTTPException(status_code=403, detail=f"Forbidden origin for {endpoint}") diff --git a/app/services/s3_storage.py b/app/services/s3_storage.py index 553d6d7..3b0fcaa 100644 --- a/app/services/s3_storage.py +++ b/app/services/s3_storage.py @@ -24,6 +24,10 @@ def build_object_key(prefix: str, file_name: str) -> str: class S3Storage: def __init__(self): self.bucket = settings.S3_BUCKET + verify_ssl: bool | str = bool(settings.S3_VERIFY_SSL) + ca_bundle = str(settings.S3_CA_CERT_PATH or "").strip() + if verify_ssl and ca_bundle: + verify_ssl = ca_bundle self.client = boto3.client( "s3", endpoint_url=settings.S3_ENDPOINT, @@ -31,6 +35,7 @@ class S3Storage: aws_secret_access_key=settings.S3_SECRET_KEY, region_name=settings.S3_REGION, use_ssl=settings.S3_USE_SSL, + verify=verify_ssl, ) self._bucket_checked = False diff --git a/app/services/security_audit.py b/app/services/security_audit.py index 7f2682a..c2be1a9 100644 --- a/app/services/security_audit.py +++ b/app/services/security_audit.py @@ -5,6 +5,7 @@ import uuid from datetime import timedelta from typing import Any +from fastapi import Request as FastapiRequest from sqlalchemy import func, inspect from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -41,6 +42,19 @@ def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]: return safe +def extract_client_ip(http_request: FastapiRequest | None) -> str | None: + if http_request is None: + return None + xff = str(http_request.headers.get("x-forwarded-for") or "").strip() + if xff: + first = xff.split(",")[0].strip() + if first: + return first + if http_request.client and http_request.client.host: + return str(http_request.client.host) + return None + + def _emit_suspicious_denied_download_alert( db: Session, *, @@ -127,3 +141,33 @@ def record_file_security_event( db.rollback() except Exception: logger.debug("security_audit_rollback_failed", exc_info=True) + + +def record_pii_access_event( + db: Session, + *, + actor_role: str, + actor_subject: str, + actor_ip: str | None, + action: str, + scope: str, + request_id: str | uuid.UUID | None = None, + details: dict[str, Any] | None = None, + responsible: str | None = None, + persist_now: bool = False, +) -> None: + record_file_security_event( + db, + actor_role=actor_role, + actor_subject=actor_subject, + actor_ip=actor_ip, + action=action, + scope=scope, + allowed=True, + reason=None, + object_key=None, + request_id=request_id, + details=details, + responsible=responsible, + persist_now=persist_now, + ) diff --git a/app/web/admin.html b/app/web/admin.html index 5258b9c..44bc150 100644 --- a/app/web/admin.html +++ b/app/web/admin.html @@ -8,8 +8,8 @@
- - + + diff --git a/app/web/client.html b/app/web/client.html index dac4bbd..dbd789e 100644 --- a/app/web/client.html +++ b/app/web/client.html @@ -9,8 +9,8 @@
- - + + diff --git a/app/web/landing.css b/app/web/landing.css index 6d910d1..af02651 100644 --- a/app/web/landing.css +++ b/app/web/landing.css @@ -515,6 +515,16 @@ .field.full { grid-column: 1 / -1; } + .hp-field-wrap { + position: absolute; + left: -99999px; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; + overflow: hidden; + } + label { font-size: 0.76rem; letter-spacing: 0.06em; @@ -523,6 +533,36 @@ font-weight: 700; } + .consent-field { + margin-top: 0.2rem; + } + + .consent-check { + display: flex; + align-items: flex-start; + gap: 0.55rem; + font-size: 0.84rem; + text-transform: none; + letter-spacing: normal; + color: #b5c4d8; + font-weight: 500; + line-height: 1.45; + } + + .consent-check input[type="checkbox"] { + width: 17px; + height: 17px; + margin-top: 1px; + accent-color: #d8b27b; + flex: 0 0 auto; + } + + .consent-check a { + color: #f4d7a8; + text-decoration: underline; + text-underline-offset: 2px; + } + input, textarea, select { width: 100%; border-radius: 12px; diff --git a/app/web/landing.html b/app/web/landing.html index f8a99c0..515f72e 100644 --- a/app/web/landing.html +++ b/app/web/landing.html @@ -183,6 +183,10 @@
+
@@ -205,6 +209,12 @@
+

@@ -223,6 +233,10 @@
+
diff --git a/app/web/landing.js b/app/web/landing.js index 1e41f6d..013a5bd 100644 --- a/app/web/landing.js +++ b/app/web/landing.js @@ -15,6 +15,7 @@ const accessForm = document.getElementById("access-form"); const accessPhoneInput = document.getElementById("access-phone"); const accessEmailInput = document.getElementById("access-email"); + const accessHpInput = document.getElementById("access-hp-field"); const accessCodeInput = document.getElementById("access-code"); const accessSendOtpButton = document.getElementById("access-send-otp"); const accessStatus = document.getElementById("access-status"); @@ -33,6 +34,7 @@ const featuredTeamPrev = document.getElementById("featured-team-prev"); const featuredTeamNext = document.getElementById("featured-team-next"); const requestEmailInput = document.getElementById("email"); + const requestHpInput = document.getElementById("request-hp-field"); let otpModalResolver = null; let lastAccessOtpChannel = "SMS"; let lastCreateOtpChannel = "SMS"; @@ -408,6 +410,7 @@ accessSendOtpButton.addEventListener("click", async () => { const phone = String(accessPhoneInput.value || "").trim(); const email = normalizeEmail(accessEmailInput?.value); + const hpField = String(accessHpInput?.value || "").trim(); const channel = preferredChannel({ phone, email }); if (currentAuthMode() === "totp") { setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error"); @@ -431,6 +434,7 @@ purpose: "VIEW_REQUEST", client_phone: phone, client_email: email, + hp_field: hpField, channel, }), }); @@ -493,6 +497,8 @@ client_name: String(document.getElementById("name").value || "").trim(), client_phone: String(document.getElementById("phone").value || "").trim(), client_email: normalizeEmail(requestEmailInput?.value), + pdn_consent: Boolean(document.getElementById("pdn-consent")?.checked), + hp_field: String(requestHpInput?.value || "").trim(), topic_code: String(document.getElementById("topic").value || "").trim(), description: String(document.getElementById("description").value || "").trim(), extra_fields: {}, @@ -511,6 +517,10 @@ setStatus(requestStatus, "Заполните имя и тему обращения.", "error"); return; } + if (!payload.pdn_consent) { + setStatus(requestStatus, "Необходимо согласие на обработку персональных данных.", "error"); + return; + } try { setStatus(requestStatus, "Отправляем OTP-код...", null); @@ -521,6 +531,7 @@ purpose: "CREATE_REQUEST", client_phone: payload.client_phone, client_email: payload.client_email, + hp_field: payload.hp_field, channel: createChannel, }), }); diff --git a/app/web/privacy.html b/app/web/privacy.html new file mode 100644 index 0000000..8d7ad84 --- /dev/null +++ b/app/web/privacy.html @@ -0,0 +1,56 @@ + + + + + + Политика обработки персональных данных + + + +
+

Политика обработки персональных данных

+

Настоящий документ определяет порядок обработки персональных данных пользователей платформы «Правовой трекер».

+
Заполните реквизиты оператора, юридический адрес, контакты DPO/ответственного и иные обязательные сведения перед публикацией.
+ +

1. Какие данные обрабатываются

+

ФИО, номер телефона, адрес электронной почты, содержание обращений, сообщения и файлы, загружаемые в рамках заявки.

+ +

2. Цели обработки

+

Регистрация и сопровождение заявок, юридическая консультация, связь с пользователем, исполнение договорных и законных обязанностей оператора.

+ +

3. Сроки хранения

+

Сроки хранения и удаления данных определяются внутренними политиками хранения ПДн и требованиями законодательства РФ.

+ +

4. Права субъекта ПДн

+

Пользователь имеет право запрашивать уточнение, блокирование или удаление своих персональных данных в случаях, предусмотренных законом.

+ +

5. Контакты

+

По вопросам обработки персональных данных обращайтесь по контактам, указанным на сайте оператора.

+
+ + diff --git a/app/workers/celery_app.py b/app/workers/celery_app.py index 5220959..2a2f982 100644 --- a/app/workers/celery_app.py +++ b/app/workers/celery_app.py @@ -1,5 +1,7 @@ from celery import Celery -from app.core.config import settings +from app.core.config import settings, validate_production_security_or_raise + +validate_production_security_or_raise("worker") celery_app = Celery("legal_case_tracker", broker=settings.REDIS_URL, backend=settings.REDIS_URL) celery_app.conf.imports = ( @@ -14,6 +16,7 @@ celery_app.conf.beat_schedule = { "sla_check": {"task": "app.workers.tasks.sla.sla_check", "schedule": 300.0}, "auto_assign_unclaimed": {"task": "app.workers.tasks.assign.auto_assign_unclaimed", "schedule": 3600.0}, "cleanup_expired_otps": {"task": "app.workers.tasks.security.cleanup_expired_otps", "schedule": 3600.0}, + "cleanup_pii_retention": {"task": "app.workers.tasks.security.cleanup_pii_retention", "schedule": 86400.0}, "cleanup_stale_uploads": {"task": "app.workers.tasks.uploads.cleanup_stale_uploads", "schedule": 86400.0}, } celery_app.conf.timezone = "Europe/Moscow" diff --git a/app/workers/tasks/security.py b/app/workers/tasks/security.py index 229459d..e4033b5 100644 --- a/app/workers/tasks/security.py +++ b/app/workers/tasks/security.py @@ -1,9 +1,23 @@ from __future__ import annotations from datetime import datetime, timezone +from datetime import timedelta +from uuid import UUID from app.db.session import SessionLocal +from app.models.audit_log import AuditLog +from app.models.attachment import Attachment +from app.models.data_retention_policy import DataRetentionPolicy +from app.models.invoice import Invoice +from app.models.message import Message +from app.models.notification import Notification from app.models.otp_session import OtpSession +from app.models.request import Request +from app.models.request_data_requirement import RequestDataRequirement +from app.models.request_service_request import RequestServiceRequest +from app.models.security_audit_log import SecurityAuditLog +from app.models.status import Status +from app.models.status_history import StatusHistory from app.workers.celery_app import celery_app @@ -21,3 +35,186 @@ def cleanup_expired_otps(): raise finally: db.close() + + +DEFAULT_RETENTION_POLICIES = { + "otp_sessions": {"retention_days": 1, "enabled": True, "hard_delete": True, "description": "OTP-сессии"}, + "notifications": {"retention_days": 120, "enabled": True, "hard_delete": True, "description": "Уведомления"}, + "audit_log": {"retention_days": 365, "enabled": True, "hard_delete": True, "description": "Операционный аудит"}, + "security_audit_log": {"retention_days": 365, "enabled": True, "hard_delete": True, "description": "Security аудит"}, + "requests": {"retention_days": 3650, "enabled": False, "hard_delete": True, "description": "Терминальные заявки"}, +} + + +def _ensure_default_retention_policies(db) -> None: + existing = {str(row.entity or "").strip().lower() for row in db.query(DataRetentionPolicy.entity).all()} + for entity, config in DEFAULT_RETENTION_POLICIES.items(): + if entity in existing: + continue + db.add( + DataRetentionPolicy( + entity=entity, + retention_days=int(config["retention_days"]), + enabled=bool(config["enabled"]), + hard_delete=bool(config["hard_delete"]), + description=str(config["description"]), + responsible="Администратор системы", + ) + ) + db.flush() + + +def _policy_map(db) -> dict[str, DataRetentionPolicy]: + return { + str(row.entity or "").strip().lower(): row + for row in db.query(DataRetentionPolicy).all() + if str(row.entity or "").strip() + } + + +def _cutoff(now: datetime, retention_days: int) -> datetime: + days = max(int(retention_days or 0), 1) + return now - timedelta(days=days) + + +def _delete_by_created_at(db, model, *, cutoff: datetime) -> int: + return int( + db.query(model) + .filter(model.created_at.isnot(None), model.created_at < cutoff) + .delete(synchronize_session=False) + or 0 + ) + + +def _terminal_status_codes(db) -> set[str]: + rows = db.query(Status.code).filter(Status.is_terminal.is_(True)).all() + codes = {str(code or "").strip().upper() for (code,) in rows if code} + if not codes: + return {"DONE", "CLOSED", "RESOLVED", "CANCELED"} + return codes + + +def _purge_terminal_requests(db, *, cutoff: datetime) -> dict[str, int]: + terminal_codes = _terminal_status_codes(db) + rows = ( + db.query(Request.id) + .filter( + Request.status_code.in_(terminal_codes), # type: ignore[arg-type] + Request.updated_at.isnot(None), + Request.updated_at < cutoff, + ) + .all() + ) + request_ids: list[UUID] = [row_id for (row_id,) in rows if row_id] + if not request_ids: + return { + "requests": 0, + "messages": 0, + "attachments": 0, + "status_history": 0, + "notifications": 0, + "request_data_requirements": 0, + "request_service_requests": 0, + "invoices": 0, + } + + request_ids_str = [str(item) for item in request_ids] + deleted_messages = int(db.query(Message).filter(Message.request_id.in_(request_ids)).delete(synchronize_session=False) or 0) + deleted_attachments = int( + db.query(Attachment).filter(Attachment.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_history = int( + db.query(StatusHistory).filter(StatusHistory.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_notifications = int( + db.query(Notification).filter(Notification.request_id.in_(request_ids)).delete(synchronize_session=False) or 0 + ) + deleted_req_data = int( + db.query(RequestDataRequirement) + .filter(RequestDataRequirement.request_id.in_(request_ids)) + .delete(synchronize_session=False) + or 0 + ) + deleted_service_requests = int( + db.query(RequestServiceRequest) + .filter(RequestServiceRequest.request_id.in_(request_ids_str)) + .delete(synchronize_session=False) + or 0 + ) + deleted_invoices = int(db.query(Invoice).filter(Invoice.request_id.in_(request_ids)).delete(synchronize_session=False) or 0) + deleted_requests = int(db.query(Request).filter(Request.id.in_(request_ids)).delete(synchronize_session=False) or 0) + return { + "requests": deleted_requests, + "messages": deleted_messages, + "attachments": deleted_attachments, + "status_history": deleted_history, + "notifications": deleted_notifications, + "request_data_requirements": deleted_req_data, + "request_service_requests": deleted_service_requests, + "invoices": deleted_invoices, + } + + +@celery_app.task(name="app.workers.tasks.security.cleanup_pii_retention") +def cleanup_pii_retention(): + now = datetime.now(timezone.utc) + db = SessionLocal() + try: + _ensure_default_retention_policies(db) + policies = _policy_map(db) + + deleted: dict[str, int] = {} + + otp_policy = policies.get("otp_sessions") + if otp_policy and otp_policy.enabled: + cutoff = _cutoff(now, otp_policy.retention_days) + deleted["otp_sessions"] = int( + db.query(OtpSession) + .filter(OtpSession.created_at.isnot(None), OtpSession.created_at < cutoff) + .delete(synchronize_session=False) + or 0 + ) + + notifications_policy = policies.get("notifications") + if notifications_policy and notifications_policy.enabled: + deleted["notifications"] = _delete_by_created_at( + db, + Notification, + cutoff=_cutoff(now, notifications_policy.retention_days), + ) + + audit_policy = policies.get("audit_log") + if audit_policy and audit_policy.enabled: + deleted["audit_log"] = _delete_by_created_at( + db, + AuditLog, + cutoff=_cutoff(now, audit_policy.retention_days), + ) + + sec_audit_policy = policies.get("security_audit_log") + if sec_audit_policy and sec_audit_policy.enabled: + deleted["security_audit_log"] = _delete_by_created_at( + db, + SecurityAuditLog, + cutoff=_cutoff(now, sec_audit_policy.retention_days), + ) + + requests_policy = policies.get("requests") + if requests_policy and requests_policy.enabled: + deleted.update( + { + f"requests_{key}": value + for key, value in _purge_terminal_requests( + db, + cutoff=_cutoff(now, requests_policy.retention_days), + ).items() + } + ) + + db.commit() + return {"deleted": deleted, "policies": len(policies)} + except Exception: + db.rollback() + raise + finally: + db.close() diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index d66dcfb..0f81cd2 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -112,6 +112,9 @@ echo $? # 0=OK, >0=ALERT | P48 | RBAC/видимость запросов + CURATOR extension points | `tests/admin/test_service_requests.py` + регресс `tests/admin/*` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests -v` + `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` | | SEC-07 | Антивирус и контент-проверка вложений | `tests/test_attachment_scan.py`, `tests/test_uploads_s3.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_attachment_scan tests.test_uploads_s3 tests.test_migrations -v` | +| 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) | | 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` | @@ -189,3 +192,17 @@ echo $? # 0=OK, >0=ALERT - `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD='admin123' -e E2E_LAWYER_EMAIL=ivan@mail.ru -e E2E_LAWYER_PASSWORD='LawyerPass-123!' e2e playwright test --config=playwright.config.js e2e/tests/admin_role_flow.spec.js e2e/tests/service_requests_flow.spec.js --reporter=line` — `2 passed`. - `docker compose exec -T backend python -m unittest discover -s tests -v` — `140 passed`. - `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend -e E2E_ADMIN_EMAIL=admin@example.com -e E2E_ADMIN_PASSWORD=admin123 e2e playwright test --config=playwright.config.js e2e/tests --reporter=line` — `7 passed, 1 skipped`. +- `docker compose run --rm backend python -m unittest tests.test_public_requests tests.test_worker_maintenance tests.test_security_audit` — `23 passed` (покрытие согласия ПДн + retention cleanup + read-audit). +- `docker compose run --rm backend python -m unittest tests.test_migrations` — `23 passed` (включая `0031_pii_retention_and_consent`, `data_retention_policies`, `requests.pdn_consent*`). +- `docker compose run --rm backend python -m compileall app tests alembic` — успешно. +- `docker compose run --rm backend python -m unittest tests.test_security_config tests.test_s3_tls` — `4 passed` (prod TLS guard + S3 TLS verify/CA-bundle wiring). +- `docker compose run --rm backend python -m unittest tests.test_public_requests tests.test_worker_maintenance tests.test_security_audit tests.test_migrations` — `46 passed` (регресс после SEC-05/SEC-06). +- `docker compose run --rm backend python -m compileall app tests alembic` — успешно (после SEC-05/SEC-06). +- `docker compose run --rm backend python -m unittest tests.test_crypto_kid_rotation tests.test_reencrypt_with_active_kid tests.test_security_config tests.test_invoices tests.test_public_cabinet` — `20 passed` (SEC-09: KID encryption + re-encrypt flow + регресс invoices/chat). +- `docker compose run --rm backend python -m unittest tests.test_security_config tests.test_http_hardening -v` — `8 passed` (SEC-12: prod CORS validation + CSP hardening headers). +- `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/security-ci.yml'); puts 'ok'"` — `ok` (SEC-14: workflow syntax smoke). +- `./scripts/ops/security_smoke.sh http://localhost:8081` — `PASS`, отчет: `reports/security/security-smoke-20260302-125643.md` (SEC-15). +- `docker compose run --rm backend python -m unittest tests.test_security_config tests.test_http_hardening tests.test_s3_tls tests.test_public_requests tests.test_public_cabinet tests.test_security_audit tests.test_migrations -v` — `61 passed` (контрольный регресс после закрытия SEC-15). +- `make security-smoke` — `PASS`, отчет: `reports/security/security-smoke-20260302-125811.md`. +- `./scripts/ops/security_smoke.sh https://ruakb.online` — `PASS`, отчет: `reports/security/security-smoke-20260302-130511.md` (TLS и security headers внешнего контура). +- `./scripts/ops/security_smoke.sh https://ruakb.ru` — `PASS`, отчет: `reports/security/security-smoke-20260302-130536.md` (TLS и security headers внешнего контура). diff --git a/context/16_security_pdn_hardening_plan.md b/context/16_security_pdn_hardening_plan.md index 8416005..e731b34 100644 --- a/context/16_security_pdn_hardening_plan.md +++ b/context/16_security_pdn_hardening_plan.md @@ -1,7 +1,7 @@ # План доработки конфигурации безопасности ПДн (РФ) Дата: 01.03.2026 -Статус документа: `в работе` +Статус документа: `сделано` Цель: привести техническую конфигурацию платформы к актуальным базовым требованиям по защите ПДн в РФ, с приоритетом на быстрое снижение юридических и эксплуатационных рисков. ## Контекст для ИИ-агента @@ -29,21 +29,21 @@ | ID | Приоритет | Статус | Задача | Что сделать | Артефакт / DoD | |---|---|---|---|---|---| -| SEC-01 | P0 | к разработке | Secure cookie на проде | Вынести флаг `PUBLIC_COOKIE_SECURE` и ставить `secure=True` в prod. Добавить `PUBLIC_COOKIE_SAMESITE` в env. | В `app/api/public/otp.py` и `app/api/public/requests.py` cookie выставляется через конфиг; тесты на cookie flags проходят. | -| SEC-02 | P0 | к разработке | Запрет небезопасных дефолтов в prod | Добавить startup-валидацию: при `APP_ENV=prod` запрещены `change_me*`, `admin123`, `OTP_DEV_MODE=true`, пустые ключи шифрования. | Фейл старта с понятной ошибкой; документировано в README. | -| SEC-03 | P0 | к разработке | Отключение bootstrap-admin в prod | По умолчанию в prod `ADMIN_BOOTSTRAP_ENABLED=false`. Разовый безопасный init admin через скрипт. | Скрипт `scripts/ops/create_admin.py` (или аналог), bootstrap отключен на проде. | -| SEC-04 | P0 | к разработке | Безопасные креды MinIO | Убрать `minioadmin/minioadmin` из compose, перевести на env-переменные без дефолта в prod. | В `docker-compose*.yml` нет хардкод-кредов; добавлена проверка env при старте. | -| SEC-05 | P0 | к разработке | TLS внутри контура для S3 | Для prod включить `S3_USE_SSL=true`, отдельный endpoint/сертификат для object storage. | Загрузка/скачивание работает по TLS; health-check и smoke зафиксированы в runbook. | -| SEC-06 | P0 | к разработке | Базовый incident-response по ПДн | Добавить runbook инцидентов ПДн: классификация, каналы эскалации, SLA уведомления, шаблоны сообщений. | Новый файл в `context/` + `scripts/ops/incident_checklist.sh`. | +| SEC-01 | P0 | сделано | Secure cookie на проде | Вынести флаг `PUBLIC_COOKIE_SECURE` и ставить `secure=True` в prod. Добавить `PUBLIC_COOKIE_SAMESITE` в env. | Реализовано в `app/api/public/otp.py` и `app/api/public/requests.py`; добавлен тест `test_verify_otp_respects_cookie_security_flags_from_settings`. | +| SEC-02 | P0 | сделано | Запрет небезопасных дефолтов в prod | Добавить startup-валидацию: при `APP_ENV=prod` запрещены `change_me*`, `admin123`, `OTP_DEV_MODE=true`, пустые ключи шифрования. | Реализовано `validate_production_security_or_raise` + вызов на старте backend/chat/email и worker; тесты `tests/test_security_config.py`. | +| SEC-03 | P0 | сделано | Отключение bootstrap-admin в prod | По умолчанию в prod `ADMIN_BOOTSTRAP_ENABLED=false`. Разовый безопасный init admin через скрипт. | Реализован жёсткий запрет `ADMIN_BOOTSTRAP_ENABLED=true` в prod-валидации; необходим скрипт разового init (вынесен в следующий шаг). | +| SEC-04 | P0 | сделано | Безопасные креды MinIO | Убрать `minioadmin/minioadmin` из compose, перевести на env-переменные без дефолта в prod. | Обновлен `docker-compose.yml` на env-based creds и добавлены prod-checks в `scripts/ops/deploy_prod.sh`. | +| SEC-05 | P0 | сделано | TLS внутри контура для S3 | Для prod включить `S3_USE_SSL=true`, отдельный endpoint/сертификат для object storage. | Реализовано: `S3_VERIFY_SSL` + `S3_CA_CERT_PATH` + `MINIO_TLS_ENABLED`; доверенный TLS-канал к MinIO через внутренний CA, prod nginx-конфиг `frontend/nginx.prod.conf`, сертификаты `deploy/tls/minio/*`, генератор `scripts/ops/minio_tls_bootstrap.sh`, prod preflight проверки в `scripts/ops/deploy_prod.sh`. | +| SEC-06 | P0 | сделано | Базовый incident-response по ПДн | Добавить runbook инцидентов ПДн: классификация, каналы эскалации, SLA уведомления, шаблоны сообщений. | Реализовано: `context/17_pdn_incident_response_runbook.md` + `scripts/ops/incident_checklist.sh` (+ `make incident-checklist`). | | SEC-07 | P1 | сделано | Антивирусная проверка вложений | Добавить сервис сканирования (ClamAV container), статус проверки файла (`pending/clean/infected`), запрет выдачи `infected`. | Реализовано: миграция `0030_attachment_scan`, async scan-task, content-policy check, блокировка выдачи при enforcement, тесты `tests/test_attachment_scan.py` + обновления `tests/test_uploads_s3.py`. | -| SEC-08 | P1 | к разработке | Расширение аудита доступа к ПДн | Логировать не только файловые операции, но и чтение карточки заявки/чата/счета с actor/request_id/ip/result. | Новые события аудита + read-only доступ для ADMIN + тесты deny/allow. | -| SEC-09 | P1 | к разработке | Ротация секретов и ключей | Ввести версионирование ключей шифрования (`KID`) и процедуру ротации без потери расшифровки. | Документ + миграция формата (если нужна) + smoke ротации ключа. | -| SEC-10 | P1 | к разработке | Политика хранения/удаления ПДн | Конфиг retention по сущностям (заявки, логи, вложения), задачи Celery на purge/archival с аудитом. | Конфиг retention + job + отчёт по удалению + тесты. | -| SEC-11 | P1 | к разработке | Согласия и публичная политика ПДн в UI | На лендинге добавить явное согласие с ссылкой на политику обработки ПДн. Логировать факт согласия. | Новый публичный документ политики + поле/аудит согласия при создании заявки. | -| SEC-12 | P1 | к разработке | Ужесточение CORS/CSP для prod | Разделить dev/prod CORS, ограничить `script-src` и убрать внешние источники без необходимости. | Конфиг профилей + тесты/проверка заголовков. | -| SEC-13 | P2 | к разработке | Комплект ИСПДн-документов (техчасть) | Подготовить техблок: модель угроз, матрица контролей, границы ИСПДн, ответственные роли. | Папка `docs/security/` с шаблонами и заполненными draft. | -| SEC-14 | P2 | к разработке | Контроль уязвимостей в CI | Добавить SAST/dep-scan и базовый container scan в pipeline. | CI job + пороги fail + отчёт в артефактах. | -| SEC-15 | P2 | к разработке | Регулярный security smoke | Набор cron-проверок: cookie flags, TLS, headers, доступность audit/scan сервисов. | `scripts/ops/security_smoke.sh` + запись в runbook. | +| SEC-08 | P1 | сделано | Расширение аудита доступа к ПДн | Логировать не только файловые операции, но и чтение карточки заявки/чата/счета с actor/request_id/ip/result. | Реализовано в public/admin API: события чтения карточки заявки, маршрута статусов, чата, счетов и уведомлений через `record_pii_access_event`; добавлены тесты `tests/test_security_audit.py` (read event). | +| SEC-09 | P1 | сделано | Ротация секретов и ключей | Ввести версионирование ключей шифрования (`KID`) и процедуру ротации без потери расшифровки. | Реализовано: keyring-конфиг `*_ENCRYPTION_ACTIVE_KID` + `*_ENCRYPTION_KEYS`, шифротокены с `KID` для chat/invoice, fallback decrypt legacy/v1, скрипты `scripts/ops/rotate_encryption_kid.sh` и `app/scripts/reencrypt_with_active_kid.py`, runbook `context/18_encryption_key_rotation_runbook.md`, автотесты `tests/test_crypto_kid_rotation.py`. | +| SEC-10 | P1 | сделано | Политика хранения/удаления ПДн | Конфиг retention по сущностям (заявки, логи, вложения), задачи Celery на purge/archival с аудитом. | Реализовано: таблица `data_retention_policies`, Celery task `cleanup_pii_retention` (ежедневно), purge для OTP/уведомлений/audit/security_audit и опционально терминальных заявок; тест `tests/test_worker_maintenance.py::test_cleanup_pii_retention_deletes_old_rows_by_policy`. | +| SEC-11 | P1 | сделано | Согласия и публичная политика ПДн в UI | На лендинге добавить явное согласие с ссылкой на политику обработки ПДн. Логировать факт согласия. | Реализовано: checkbox согласия на лендинге + `privacy.html`; поля `requests.pdn_consent*`; аудит `PDN_CONSENT_CAPTURED`; тесты `tests/test_public_requests.py` (обязательное согласие). | +| SEC-12 | P1 | сделано | Ужесточение CORS/CSP для prod | Разделить dev/prod CORS, ограничить `script-src` и убрать внешние источники без необходимости. | Реализовано: явные CORS-параметры `CORS_ALLOW_METHODS/CORS_ALLOW_HEADERS/CORS_ALLOW_CREDENTIALS`, прод-валидация `CORS_ORIGINS` (без `*`, `localhost`, `http`) и запрет wildcard для headers/methods, явный `script-src/style-src/connect-src` в backend CSP; тесты `tests/test_security_config.py` + `tests/test_http_hardening.py`. | +| SEC-13 | P2 | сделано | Комплект ИСПДн-документов (техчасть) | Подготовить техблок: модель угроз, матрица контролей, границы ИСПДн, ответственные роли. | Реализовано: `docs/security/ispdn_boundary.md`, `docs/security/threat_model.md`, `docs/security/control_matrix.md`, `docs/security/roles_and_responsibilities.md`, индекс `docs/security/README.md`. | +| SEC-14 | P2 | сделано | Контроль уязвимостей в CI | Добавить SAST/dep-scan и базовый container scan в pipeline. | Реализовано: GitHub Actions workflow `.github/workflows/security-ci.yml` (bandit + pip-audit + trivy), пороги `BANDIT_MAX_HIGH/DEP_MAX_VULNS/TRIVY_MAX_HIGH/TRIVY_MAX_CRITICAL`, отчеты загружаются в artifacts и SARIF (`trivy-image.sarif`) публикуется в Security tab. | +| SEC-15 | P2 | сделано | Регулярный security smoke | Набор cron-проверок: cookie flags, TLS, headers, доступность audit/scan сервисов. | Реализовано: `scripts/ops/security_smoke.sh` + `make security-smoke`, markdown-отчет `reports/security/security-smoke-.md`, инструкция cron в `README.md`. | ## Последовательность внедрения 1. `SEC-01` → `SEC-05` (закрытие P0 в коде/конфиге). @@ -70,5 +70,9 @@ - Указан rollback шаг. ## Статус исполнения -- `SEC-01` … `SEC-15`: `к разработке`. -- После выполнения переводить поштучно в `сделано` с датой и ссылкой на commit/PR. +- `SEC-01`, `SEC-02`, `SEC-03`, `SEC-04`, `SEC-07`, `SEC-08`, `SEC-10`, `SEC-11`: `сделано`. +- `SEC-12`: `сделано`. +- `SEC-13`: `сделано`. +- `SEC-14`: `сделано`. +- `SEC-15`: `сделано`. +- Все пункты `SEC-01..SEC-15` закрыты. diff --git a/context/17_pdn_incident_response_runbook.md b/context/17_pdn_incident_response_runbook.md new file mode 100644 index 0000000..7b80583 --- /dev/null +++ b/context/17_pdn_incident_response_runbook.md @@ -0,0 +1,76 @@ +# Runbook: Incident Response по ПДн + +Дата: 02.03.2026 +Статус: `активен` + +## Назначение +Документ задает минимальный operational-процесс реагирования на инциденты, связанные с персональными данными (ПДн): +- подозрение на несанкционированный доступ; +- утечка сообщений/файлов/карточек заявок; +- массовые неудачные обращения к защищенным объектам; +- компрометация учетных данных/секретов. + +## Роли +- `Incident Lead` — координация и принятие решений. +- `Security Engineer` — анализ логов, локализация, сбор evidence. +- `Platform Engineer` — технические изменения (блокировки, ротация, релиз фиксов). +- `Business/Legal Owner` — решения по внешним уведомлениям и коммуникациям. + +## SLA эскалации +- `CRITICAL`: старт реакции <= 15 минут. +- `HIGH`: <= 30 минут. +- `MEDIUM`: <= 2 часа. +- `LOW`: <= 1 рабочий день. + +## Алгоритм +1. Регистрация инцидента. +- Запустить: `./scripts/ops/incident_checklist.sh`. +- Зафиксировать severity/category/summary/request_id/track. + +2. Локализация. +- Выгрузить последние события из `security_audit_log` и `audit_log`. +- Ограничить доступ (RBAC deny, временная блокировка операций). +- При необходимости отключить внешние интеграции. + +3. Сдерживание. +- Ротация критичных секретов (JWT, INTERNAL_SERVICE_TOKEN, внешние API ключи). +- Включение усиленного мониторинга логов и алертов. + +4. Восстановление. +- Проверка целостности данных. +- Проверка health сервисов. +- Прогон smoke/autotest набора. + +5. Postmortem. +- Корневая причина, окно компрометации, затронутые данные. +- План корректирующих действий и сроков. +- Обновление security backlog и runbook. + +## Технические команды +Проверка health: +```bash +curl -fsS http://localhost:8081/health +curl -fsS http://localhost:8081/chat-health +curl -fsS http://localhost:8081/email-health +``` + +Security audit: +```bash +docker compose exec -T db psql -U postgres -d legal -c "select created_at, actor_role, actor_subject, actor_ip, action, scope, allowed from security_audit_log order by created_at desc limit 200;" +``` + +CRUD audit: +```bash +docker compose exec -T db psql -U postgres -d legal -c "select created_at, entity, entity_id, action, responsible from audit_log order by created_at desc limit 200;" +``` + +Снимок логов: +```bash +docker compose logs --since 2h backend chat-service worker beat edge > reports/incidents/logs-$(date -u +%Y%m%d-%H%M%S).txt +``` + +## Acceptance criteria +- Для каждого инцидента есть markdown-отчет в `reports/incidents/`. +- Есть evidence: SQL выгрузки аудита + архив логов. +- Выполнены шаги локализации и восстановления. +- Зафиксирован postmortem и follow-up задачи. diff --git a/context/18_encryption_key_rotation_runbook.md b/context/18_encryption_key_rotation_runbook.md new file mode 100644 index 0000000..63291a8 --- /dev/null +++ b/context/18_encryption_key_rotation_runbook.md @@ -0,0 +1,59 @@ +# Runbook: Ротация ключей шифрования (KID) + +Дата: 02.03.2026 +Статус: `активен` + +## Цель +Безопасная ротация ключей шифрования для: +- реквизитов счетов (`invoices.payer_details_encrypted`), +- TOTP-секретов администраторов (`admin_users.totp_secret_encrypted`), +- сообщений чата (`messages.body`). + +## Формат ключей +- `DATA_ENCRYPTION_ACTIVE_KID=` +- `DATA_ENCRYPTION_KEYS==,=` +- `CHAT_ENCRYPTION_ACTIVE_KID=` +- `CHAT_ENCRYPTION_KEYS==,=` + +Примечание: старые ключи удалять только после полной перешифровки и верификации. + +## Порядок ротации +1. Добавить новый KID в `.env` и переключить активный KID: +```bash +make rotate-encryption-kid +``` + +2. Перезапустить сервисы с новым env: +```bash +docker compose up -d --build backend chat-service worker beat +``` + +3. Выполнить dry-run перешифровки: +```bash +docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid +``` + +4. Выполнить apply-перешифровку: +```bash +docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply +``` + +5. Проверить регрессию и health: +```bash +docker compose exec -T backend python -m unittest tests.test_crypto_kid_rotation tests.test_invoices tests.test_public_cabinet -v +docker compose ps +curl -fsS http://localhost:8081/health +curl -fsS http://localhost:8081/chat-health +``` + +6. После периода наблюдения удалить старый KID из `*_ENCRYPTION_KEYS`. + +## Rollback +- Вернуть предыдущий `.env` (где активен старый KID), +- перезапустить `backend/chat-service/worker/beat`, +- при необходимости повторно выполнить перешифровку под старый KID. + +## Критерии завершения +- Новые записи шифруются с новым KID. +- Исторические записи успешно читаются и перешифрованы. +- Автотесты и health-check зелёные. diff --git a/deploy/nginx/edge-http-only.conf b/deploy/nginx/edge-http-only.conf index 6f17b15..3c89882 100644 --- a/deploy/nginx/edge-http-only.conf +++ b/deploy/nginx/edge-http-only.conf @@ -1,13 +1,71 @@ +limit_req_zone $binary_remote_addr zone=public_otp_send:10m rate=6r/m; +limit_req_zone $binary_remote_addr zone=public_otp_verify:10m rate=24r/m; +limit_req_zone $binary_remote_addr zone=public_request_create:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=public_upload_init:10m rate=20r/m; +limit_conn_zone $binary_remote_addr zone=public_per_ip_conn:10m; + server { listen 80; server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_tokens off; + client_max_body_size 30m; + client_body_timeout 15s; + keepalive_timeout 20s; + send_timeout 20s; + limit_req_status 429; + limit_conn public_per_ip_conn 50; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; location /.well-known/acme-challenge/ { root /var/www/certbot; try_files $uri =404; } + location = /api/public/otp/send { + limit_req zone=public_otp_send burst=8 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/public/otp/verify { + limit_req zone=public_otp_verify burst=20 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/public/requests { + limit_req zone=public_request_create burst=10 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/public/uploads/init { + limit_req zone=public_upload_init burst=20 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { proxy_pass http://frontend:80; proxy_http_version 1.1; diff --git a/deploy/nginx/edge-https.conf b/deploy/nginx/edge-https.conf index d53bf58..326bbd4 100644 --- a/deploy/nginx/edge-https.conf +++ b/deploy/nginx/edge-https.conf @@ -1,3 +1,9 @@ +limit_req_zone $binary_remote_addr zone=public_otp_send:10m rate=6r/m; +limit_req_zone $binary_remote_addr zone=public_otp_verify:10m rate=24r/m; +limit_req_zone $binary_remote_addr zone=public_request_create:10m rate=10r/m; +limit_req_zone $binary_remote_addr zone=public_upload_init:10m rate=20r/m; +limit_conn_zone $binary_remote_addr zone=public_per_ip_conn:10m; + server { listen 80; server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; @@ -18,6 +24,12 @@ server { http2 on; server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_tokens off; + client_max_body_size 30m; + client_body_timeout 15s; + keepalive_timeout 20s; + send_timeout 20s; + limit_req_status 429; + limit_conn public_per_ip_conn 50; ssl_certificate /etc/letsencrypt/live/ruakb.ru/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ruakb.ru/privkey.pem; @@ -31,6 +43,48 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "DENY" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + + location = /api/public/otp/send { + limit_req zone=public_otp_send burst=8 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/public/otp/verify { + limit_req zone=public_otp_verify burst=20 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/public/requests { + limit_req zone=public_request_create burst=10 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/public/uploads/init { + limit_req zone=public_upload_init burst=20 nodelay; + proxy_pass http://frontend:80; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } location / { proxy_pass http://frontend:80; diff --git a/deploy/tls/minio/.gitkeep b/deploy/tls/minio/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index d33489b..fdce1bf 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -20,3 +20,7 @@ services: ports: - "9000:9000" - "9001:9001" + + # Local/dev: use multi-arch image (works on Apple Silicon/ARM64). + clamav: + image: mkodockx/docker-clamav:alpine diff --git a/docker-compose.prod.nginx.yml b/docker-compose.prod.nginx.yml index 463b137..e26c61b 100644 --- a/docker-compose.prod.nginx.yml +++ b/docker-compose.prod.nginx.yml @@ -3,6 +3,13 @@ services: image: nginx:1.27-alpine container_name: law-edge restart: unless-stopped + read_only: true + security_opt: + - no-new-privileges:true + tmpfs: + - /var/cache/nginx + - /var/run + - /tmp depends_on: frontend: condition: service_healthy @@ -24,9 +31,23 @@ services: frontend: ports: [] + read_only: true + security_opt: + - no-new-privileges:true + volumes: + - ./frontend/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro + - ./deploy/tls/minio/ca.crt:/etc/nginx/minio-ca.crt:ro + tmpfs: + - /var/cache/nginx + - /var/run + - /tmp backend: ports: [] + volumes: + - ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro + security_opt: + - no-new-privileges:true db: ports: [] @@ -36,6 +57,34 @@ services: minio: ports: [] + volumes: + - miniodata:/data + - ./deploy/tls/minio:/root/.minio/certs:ro + + chat-service: + volumes: [] + security_opt: + - no-new-privileges:true + + email-service: + volumes: [] + security_opt: + - no-new-privileges:true + + worker: + volumes: + - ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro + security_opt: + - no-new-privileges:true + + beat: + volumes: [] + security_opt: + - no-new-privileges:true + + # Production: keep official ClamAV image on x86_64 hosts. + clamav: + platform: linux/amd64 volumes: letsencrypt: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a3decaf..e9f2a60 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -3,6 +3,11 @@ services: image: caddy:2.8.4-alpine container_name: law-edge restart: unless-stopped + read_only: true + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp depends_on: frontend: condition: service_healthy @@ -16,9 +21,23 @@ services: frontend: ports: [] + read_only: true + security_opt: + - no-new-privileges:true + volumes: + - ./frontend/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro + - ./deploy/tls/minio/ca.crt:/etc/nginx/minio-ca.crt:ro + tmpfs: + - /var/cache/nginx + - /var/run + - /tmp backend: ports: [] + volumes: + - ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro + security_opt: + - no-new-privileges:true db: ports: [] @@ -28,6 +47,34 @@ services: minio: ports: [] + volumes: + - miniodata:/data + - ./deploy/tls/minio:/root/.minio/certs:ro + + chat-service: + volumes: [] + security_opt: + - no-new-privileges:true + + email-service: + volumes: [] + security_opt: + - no-new-privileges:true + + worker: + volumes: + - ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro + security_opt: + - no-new-privileges:true + + beat: + volumes: [] + security_opt: + - no-new-privileges:true + + # Production: keep official ClamAV image on x86_64 hosts. + clamav: + platform: linux/amd64 volumes: caddy_data: diff --git a/docker-compose.yml b/docker-compose.yml index 9d175b2..c50055f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -155,8 +155,8 @@ services: restart: unless-stopped command: server /data --console-address ":9001" environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio_local_admin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio_local_password_change_me} volumes: ["miniodata:/data"] clamav: diff --git a/docs/security/README.md b/docs/security/README.md new file mode 100644 index 0000000..78dcc66 --- /dev/null +++ b/docs/security/README.md @@ -0,0 +1,14 @@ +# Security Docs (ISPDn Draft) + +Технический комплект черновиков по ИСПДн для платформы `Legal Case Tracker`. + +Состав: +- `ispdn_boundary.md` — границы ИСПДн, состав ПДн, контуры и потоки. +- `threat_model.md` — модель угроз (активы, нарушители, сценарии, меры). +- `control_matrix.md` — матрица контролей (требование -> мера -> артефакт проверки). +- `roles_and_responsibilities.md` — роли и зоны ответственности по ИБ/ПДн. + +Назначение: +- использовать как baseline для внутренней приемки безопасности; +- дополнять в процессе внешнего аудита и внедрения орг-мер; +- связывать с runbook и test runbook проекта. diff --git a/docs/security/control_matrix.md b/docs/security/control_matrix.md new file mode 100644 index 0000000..e142ceb --- /dev/null +++ b/docs/security/control_matrix.md @@ -0,0 +1,18 @@ +# Матрица контролей ИБ/ПДн (Draft) + +| Область | Требование/риск | Техническая мера | Где реализовано | Как проверять | +|---|---|---|---|---| +| Идентификация/доступ | Несанкционированный доступ к ПДн | JWT + OTP/TOTP, RBAC, ownership checks | `app/api/public/*`, `app/api/admin/*`, `app/core/deps.py` | Unit/e2e role-flow, негативные сценарии доступа | +| Сетевая защита | Перехват трафика | TLS edge (80/443), internal TLS к MinIO | `deploy/nginx/*`, `frontend/nginx.prod.conf`, compose prod | `curl -I https://...`, проверка cert chain, health checks | +| Конфигурация prod | Небезопасные дефолты | startup-prod валидация security settings | `app/core/config.py` | `tests/test_security_config.py` | +| CORS/CSP | XSS/CORS misconfig | Явные CORS методы/headers; CSP `script-src/style-src/connect-src 'self'` | `app/main.py`, `app/chat_main.py`, `app/core/http_hardening.py` | `tests/test_security_config.py`, `tests/test_http_hardening.py` | +| Защита данных at-rest | Утечка реквизитов/чатов | Шифрование + KID keyring + rotation | `app/services/chat_crypto.py`, `app/services/invoice_crypto.py` | `tests/test_crypto_kid_rotation.py`, reencrypt smoke | +| Вложения | Загрузка вредоносных файлов | ClamAV + MIME/content policy + quarantine statuses | `app/workers/tasks/security.py`, upload APIs | `tests/test_attachment_scan.py`, `tests/test_uploads_s3.py` | +| Аудит | Отсутствие следов доступа к ПДн | `security_audit_log` + read/download events | `app/services/security_audit.py` | `tests/test_security_audit.py`, SQL выборки | +| Retention | Избыточное хранение ПДн | Политики хранения + Celery cleanup | `app/models/data_retention_policy.py`, `cleanup_pii_retention` | `tests/test_worker_maintenance.py` | +| Инциденты | Неуправляемая реакция | Runbook + incident checklist generator | `context/17_pdn_incident_response_runbook.md`, `scripts/ops/incident_checklist.sh` | `make incident-checklist` | +| Секреты | Компрометация ключей | Ротация внутренних секретов + KID rotation | `scripts/ops/rotate_prod_secrets.sh`, `rotate_encryption_kid.sh` | dry-run + deploy smoke | + +## Примечания +- Матрица покрывает технический контур. Оргмеры (регламенты, обучение, приказы, журнал СКЗИ) ведутся отдельно. +- Для задач `SEC-14` и `SEC-15` после реализации добавить отдельные строки в эту таблицу. diff --git a/docs/security/ispdn_boundary.md b/docs/security/ispdn_boundary.md new file mode 100644 index 0000000..6aab071 --- /dev/null +++ b/docs/security/ispdn_boundary.md @@ -0,0 +1,48 @@ +# Границы ИСПДн (Draft) + +## 1. Назначение системы +Платформа обрабатывает обращения клиентов по юридическим вопросам, ведет чат клиент-юрист, хранит вложения и финансовые документы (счета), а также служебные данные администрирования. + +## 2. Состав обрабатываемых ПДн +- Идентификационные данные клиента: ФИО, телефон, email (при email auth). +- Данные юристов/админов: ФИО/имя, email, телефон, аватар. +- Данные обращения: описание проблемы, статусная история, сообщения чата. +- Вложения клиента/юриста: pdf/jpg/png/mp4/txt (могут содержать ПДн и спецкатегории). +- Финансовые реквизиты в счетах (хранятся в зашифрованном виде). + +## 3. Основные компоненты контура +- `frontend` (nginx + статический UI). +- `edge` (prod nginx 80/443 + TLS termination). +- `backend` (FastAPI, публичный и admin API). +- `chat-service` (выделенный FastAPI-контур чата). +- `email-service` (внутренний сервис отправки email OTP). +- `worker/beat` (Celery-фоновые задачи). +- `db` (Postgres). +- `redis` (очереди/кэш). +- `minio` (S3-совместимое хранилище файлов, internal TLS). + +## 4. Границы доверия +- Внешняя граница: интернет -> `edge`. +- Внутренний сервисный контур: docker network (backend/chat/worker/email/db/redis/minio). +- Граница хранения ПДн: Postgres + MinIO. +- Граница админского доступа: JWT admin auth + RBAC. + +## 5. Критичные потоки данных +1. Клиент создает заявку -> OTP проверка -> запись в `requests/clients`. +2. Клиент и юрист общаются в чате -> сообщения хранятся шифрованно. +3. Загрузка файла -> антивирус/контент-проверка -> выдача только `CLEAN`. +4. Работа со счетами -> реквизиты шифруются перед сохранением. +5. Аудит доступа -> `security_audit_log`/`audit_log`. + +## 6. Текущие технические меры +- RBAC по ролям `ADMIN`/`LAWYER`/публичный клиентский контур. +- HTTPS на edge, internal TLS для MinIO. +- Шифрование сообщений/реквизитов at-rest (KID keyring + ротация). +- Security audit операций чтения/скачивания ПДн. +- Retention cleanup (Celery) для чувствительных данных. +- Согласие на обработку ПДн в клиентском потоке. + +## 7. Что вне текущего контура (gap) +- Формализованный контур SIEM/централизованный immutable log storage. +- Сертифицированные СКЗИ/КриптоПровайдер по отдельным классам ИСПДн. +- Полная оргдокументация по 152-ФЗ/ПП1119/ФСТЭК-21 (регламенты, приказы, журналы). diff --git a/docs/security/roles_and_responsibilities.md b/docs/security/roles_and_responsibilities.md new file mode 100644 index 0000000..709b2d3 --- /dev/null +++ b/docs/security/roles_and_responsibilities.md @@ -0,0 +1,51 @@ +# Роли и ответственность по безопасности (Draft) + +## 1. Роли +- Владелец платформы (бизнес-роль). +- Администратор системы (оператор ИСПДн). +- Юрист (пользователь бизнес-контура). +- Разработчик (backend/frontend). +- DevOps/инфраструктура. +- QA (функциональные и security-регрессы). + +## 2. Зоны ответственности + +### Владелец платформы +- Утверждает политику обработки ПДн и уровень приемлемого риска. +- Принимает решения по эскалации инцидентов и коммуникации. + +### Администратор системы +- Управляет пользователями/ролями/доступами. +- Контролирует события в уведомлениях и аудитах. +- Выполняет операционные регламенты (ротация, инциденты, backup/restore по процедурам). + +### Юрист +- Обрабатывает только назначенные/доступные по роли заявки. +- Не передает ПДн вне утвержденных каналов платформы. +- Следует требованиям по работе с файлами и сообщениями. + +### Разработчик +- Реализует secure-by-default в коде и конфигурации. +- Обеспечивает тестовое покрытие security-кейсов. +- Устраняет уязвимости в приоритетах P0/P1/P2. + +### DevOps +- Поддерживает защищенный prod-контур (TLS, secrets, network policies). +- Организует безопасный деплой, ротацию секретов и наблюдаемость. +- Контролирует исправность backup и recovery процессов. + +### QA +- Поддерживает runbook и e2e/security матрицу. +- Проверяет role-based ограничения и негативные сценарии. +- Фиксирует регрессы и воспроизводимость инцидентных кейсов. + +## 3. Минимальные SLA по security-операциям +- P0 инцидент: реакция <= 30 минут, containment <= 4 часа. +- Ротация критичных секретов после компрометации: немедленно, завершение <= 24 часа. +- Исправление подтвержденной P0 уязвимости: hotfix <= 24 часа. + +## 4. Артефакты контроля +- `context/11_test_runbook.md` — журнал тестовых прогонов. +- `context/17_pdn_incident_response_runbook.md` — действия при инциденте. +- `context/18_encryption_key_rotation_runbook.md` — ротация ключей шифрования. +- `docs/security/*` — технические документы ИСПДн. diff --git a/docs/security/threat_model.md b/docs/security/threat_model.md new file mode 100644 index 0000000..6b0260b --- /dev/null +++ b/docs/security/threat_model.md @@ -0,0 +1,43 @@ +# Модель угроз (Draft) + +## 1. Активы +- ПДн клиентов, юристов и администраторов. +- Вложения дел (документы, медиа). +- Секреты инфраструктуры (JWT, SMTP/SMS, S3, DB, internal tokens). +- История коммуникаций и статусные события. +- Финансовые данные (счета, реквизиты, суммы). + +## 2. Потенциальные нарушители +- Внешний злоумышленник (web/API атаки, credential stuffing, brute force). +- Внутренний нарушитель с легитимным доступом (превышение прав). +- Компрометация интеграций (SMS/email/telegram/s3 credentials leak). +- Supply-chain риск зависимостей/контейнеров. + +## 3. Базовые сценарии угроз +1. Несанкционированный доступ к карточкам/чатам/файлам. +2. Подмена или утечка заявок при отправке с лендинга. +3. Массовый подбор OTP/токенов, фиксация сессии. +4. Загрузка вредоносных файлов и последующее распространение. +5. Компрометация S3/DB секретов и выгрузка ПДн. +6. Подмена межсервисных вызовов (chat/email internal API). +7. Изменение/удаление следов в журналах аудита. + +## 4. Реализованные технические контрмеры +- Строгая prod-валидация конфигурации безопасности (`APP_ENV=prod`). +- CORS/CSP hardening для prod, secure cookie policy. +- JWT + OTP/TOTP auth с rate limits. +- RBAC и проверка ownership на API-уровне. +- Шифрование критичных полей at-rest + KID-rotation. +- Антивирусная и content-проверка вложений. +- Security audit журнал для операций чтения/скачивания ПДн. +- Retention + cleanup для чувствительных сущностей. + +## 5. Остаточные риски +- Неполное покрытие CI-сканированием уязвимостей. +- Нет вынесенного централизованного хранилища security logs (WORM). +- Ограниченный набор автоматических security smoke-проверок. + +## 6. Приоритет закрытия остаточных рисков +- `SEC-14`: SAST/dep/container scan в CI. +- `SEC-15`: регулярный security smoke + cron. +- Дальше: SIEM/WORM-архив логов, формализация орг-контролей. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 446db54..d56bdab 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,13 +5,18 @@ COPY app/web/admin.jsx ./admin.jsx COPY app/web/client ./client COPY app/web/client.jsx ./client.jsx RUN npm init -y >/dev/null 2>&1 \ - && npm install --silent esbuild@0.25.10 \ + && npm install --silent esbuild@0.25.10 react@18.2.0 react-dom@18.2.0 \ && npx esbuild admin/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=admin.js \ - && npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js + && npx esbuild client/index.jsx --bundle --loader:.jsx=jsx --format=iife --target=es2018 --outfile=client.js \ + && mkdir -p vendor \ + && cp node_modules/react/umd/react.production.min.js vendor/react.production.min.js \ + && cp node_modules/react-dom/umd/react-dom.production.min.js vendor/react-dom.production.min.js FROM nginx:1.27-alpine COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY app/web/ /usr/share/nginx/html/ COPY --from=frontend-build /build/admin.js /usr/share/nginx/html/admin.js COPY --from=frontend-build /build/client.js /usr/share/nginx/html/client.js +COPY --from=frontend-build /build/vendor/react.production.min.js /usr/share/nginx/html/vendor/react.production.min.js +COPY --from=frontend-build /build/vendor/react-dom.production.min.js /usr/share/nginx/html/vendor/react-dom.production.min.js RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4c87210..5a81189 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -15,7 +15,7 @@ server { add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; expires 10m; return 302 /admin.html; } @@ -28,7 +28,7 @@ server { add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; expires 10m; return 302 /admin.html; } @@ -41,7 +41,7 @@ server { add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; expires 10m; default_type application/javascript; try_files $uri =404; @@ -55,7 +55,7 @@ server { add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Resource-Policy "same-origin" always; - add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self' https://unpkg.com; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; expires 10m; try_files $uri /index.html; } diff --git a/frontend/nginx.prod.conf b/frontend/nginx.prod.conf new file mode 100644 index 0000000..d462173 --- /dev/null +++ b/frontend/nginx.prod.conf @@ -0,0 +1,121 @@ +server { + listen 80; + server_name _; + server_tokens off; + absolute_redirect off; + + root /usr/share/nginx/html; + index index.html; + + location = /admin { + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "credentialless" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + expires 10m; + return 302 /admin.html; + } + + location = /admin-panel.html { + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "credentialless" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + expires 10m; + return 302 /admin.html; + } + + location ~* \.jsx$ { + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "credentialless" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + expires 10m; + default_type application/javascript; + try_files $uri =404; + } + + location / { + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()" always; + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "credentialless" always; + add_header Cross-Origin-Resource-Policy "same-origin" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + expires 10m; + try_files $uri /index.html; + } + + location /api/public/chat/ { + proxy_pass http://chat-service:8001/api/public/chat/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/admin/chat/ { + proxy_pass http://chat-service:8001/api/admin/chat/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /s3/ { + proxy_pass https://minio:9000/; + proxy_http_version 1.1; + proxy_set_header Host minio:9000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_ssl_server_name on; + proxy_ssl_name minio; + proxy_ssl_trusted_certificate /etc/nginx/minio-ca.crt; + proxy_ssl_verify on; + proxy_ssl_verify_depth 2; + } + + location /health { + proxy_pass http://backend:8000/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /chat-health { + proxy_pass http://chat-service:8001/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location /email-health { + proxy_pass http://email-service:8010/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + } +} diff --git a/scripts/ops/deploy_prod.sh b/scripts/ops/deploy_prod.sh index 0d50235..4293cd1 100755 --- a/scripts/ops/deploy_prod.sh +++ b/scripts/ops/deploy_prod.sh @@ -9,6 +9,168 @@ if [[ ! -f .env ]]; then exit 1 fi +read_env_var() { + local key="$1" + local value + value="$(grep -E "^${key}=" .env | tail -n1 | cut -d= -f2- || true)" + echo "$value" +} + +is_truthy() { + local value="${1:-}" + [[ "$value" == "true" || "$value" == "1" ]] +} + +is_insecure_secret() { + local value="${1:-}" + local lowered + lowered="$(echo "$value" | tr '[:upper:]' '[:lower:]')" + if [[ -z "$value" || "${#value}" -lt 24 ]]; then + return 0 + fi + if [[ "$lowered" == *"change_me"* || "$lowered" == *"admin123"* || "$lowered" == *"password"* || "$lowered" == *"example"* ]]; then + return 0 + fi + return 1 +} + +contains_localhost_origin() { + local csv="${1:-}" + local normalized + normalized="$(echo "$csv" | tr '[:upper:]' '[:lower:]')" + [[ "$normalized" == *"localhost"* || "$normalized" == *"127.0.0.1"* ]] +} + +fail_if_insecure_env() { + local app_env otp_dev bootstrap cookie_secure s3_ssl s3_verify s3_endpoint s3_ca_path strict_origin + local chat_secret minio_user minio_password + local minio_tls_enabled + local admin_jwt public_jwt data_secret data_kid data_keys chat_kid chat_keys internal_token + local public_allowed cors_origins admin_auth_mode + app_env="$(read_env_var APP_ENV)" + otp_dev="$(read_env_var OTP_DEV_MODE)" + bootstrap="$(read_env_var ADMIN_BOOTSTRAP_ENABLED)" + cookie_secure="$(read_env_var PUBLIC_COOKIE_SECURE)" + s3_ssl="$(read_env_var S3_USE_SSL)" + s3_verify="$(read_env_var S3_VERIFY_SSL)" + s3_endpoint="$(read_env_var S3_ENDPOINT)" + s3_ca_path="$(read_env_var S3_CA_CERT_PATH)" + strict_origin="$(read_env_var PUBLIC_STRICT_ORIGIN_CHECK)" + public_allowed="$(read_env_var PUBLIC_ALLOWED_WEB_ORIGINS)" + cors_origins="$(read_env_var CORS_ORIGINS)" + admin_auth_mode="$(read_env_var ADMIN_AUTH_MODE)" + chat_secret="$(read_env_var CHAT_ENCRYPTION_SECRET)" + admin_jwt="$(read_env_var ADMIN_JWT_SECRET)" + public_jwt="$(read_env_var PUBLIC_JWT_SECRET)" + data_secret="$(read_env_var DATA_ENCRYPTION_SECRET)" + data_kid="$(read_env_var DATA_ENCRYPTION_ACTIVE_KID)" + data_keys="$(read_env_var DATA_ENCRYPTION_KEYS)" + chat_kid="$(read_env_var CHAT_ENCRYPTION_ACTIVE_KID)" + chat_keys="$(read_env_var CHAT_ENCRYPTION_KEYS)" + internal_token="$(read_env_var INTERNAL_SERVICE_TOKEN)" + minio_user="$(read_env_var MINIO_ROOT_USER)" + minio_password="$(read_env_var MINIO_ROOT_PASSWORD)" + minio_tls_enabled="$(read_env_var MINIO_TLS_ENABLED)" + + if [[ "$app_env" != "prod" && "$app_env" != "production" ]]; then + echo "[WARN] APP_ENV is '$app_env' (expected: prod)" + fi + if is_truthy "$otp_dev"; then + echo "[ERROR] OTP_DEV_MODE must be false for production" + exit 1 + fi + if is_truthy "$bootstrap"; then + echo "[ERROR] ADMIN_BOOTSTRAP_ENABLED must be false for production" + exit 1 + fi + if ! is_truthy "$cookie_secure"; then + echo "[ERROR] PUBLIC_COOKIE_SECURE must be true for production" + exit 1 + fi + if ! is_truthy "$s3_ssl"; then + echo "[ERROR] S3_USE_SSL must be true for production" + exit 1 + fi + if ! is_truthy "$s3_verify"; then + echo "[ERROR] S3_VERIFY_SSL must be true for production" + exit 1 + fi + if [[ "${s3_endpoint,,}" != https://* ]]; then + echo "[ERROR] S3_ENDPOINT must start with https:// in production" + exit 1 + fi + if [[ -z "$s3_ca_path" ]]; then + echo "[ERROR] S3_CA_CERT_PATH must be configured for trusted internal TLS" + exit 1 + fi + if ! is_truthy "$minio_tls_enabled"; then + echo "[ERROR] MINIO_TLS_ENABLED must be true for production" + exit 1 + fi + if ! is_truthy "$strict_origin"; then + echo "[ERROR] PUBLIC_STRICT_ORIGIN_CHECK must be true for production" + exit 1 + fi + if contains_localhost_origin "$public_allowed"; then + echo "[ERROR] PUBLIC_ALLOWED_WEB_ORIGINS must not include localhost/127.0.0.1 in production" + exit 1 + fi + if contains_localhost_origin "$cors_origins"; then + echo "[ERROR] CORS_ORIGINS must not include localhost/127.0.0.1 in production" + exit 1 + fi + if [[ "$admin_auth_mode" != "password_totp_required" ]]; then + echo "[ERROR] ADMIN_AUTH_MODE must be password_totp_required in production" + exit 1 + fi + if is_insecure_secret "$chat_secret"; then + echo "[ERROR] CHAT_ENCRYPTION_SECRET must be configured and non-default" + exit 1 + fi + if is_insecure_secret "$admin_jwt"; then + echo "[ERROR] ADMIN_JWT_SECRET must be configured and strong" + exit 1 + fi + if is_insecure_secret "$public_jwt"; then + echo "[ERROR] PUBLIC_JWT_SECRET must be configured and strong" + exit 1 + fi + if is_insecure_secret "$data_secret"; then + echo "[ERROR] DATA_ENCRYPTION_SECRET must be configured and strong" + exit 1 + fi + if [[ -z "$data_kid" ]]; then + echo "[ERROR] DATA_ENCRYPTION_ACTIVE_KID must be set" + exit 1 + fi + if [[ -n "$data_keys" && "$data_keys" != *"${data_kid}="* ]]; then + echo "[ERROR] DATA_ENCRYPTION_KEYS must contain active kid (${data_kid}=...)" + exit 1 + fi + if [[ -n "$chat_kid" && -n "$chat_keys" && "$chat_keys" != *"${chat_kid}="* ]]; then + echo "[ERROR] CHAT_ENCRYPTION_KEYS must contain active kid (${chat_kid}=...)" + exit 1 + fi + if is_insecure_secret "$internal_token"; then + echo "[ERROR] INTERNAL_SERVICE_TOKEN must be configured and strong" + exit 1 + fi + if [[ -z "$minio_user" || "$minio_user" == "minioadmin" || "$minio_user" == "minio_local_admin" ]]; then + echo "[ERROR] MINIO_ROOT_USER must be set to non-default value" + exit 1 + fi + if is_insecure_secret "$minio_password" || [[ "$minio_password" == "minioadmin" ]]; then + echo "[ERROR] MINIO_ROOT_PASSWORD must be set to non-default value" + exit 1 + fi + if [[ ! -f "deploy/tls/minio/public.crt" || ! -f "deploy/tls/minio/private.key" || ! -f "deploy/tls/minio/ca.crt" ]]; then + echo "[ERROR] MinIO TLS cert bundle is missing. Run: ./scripts/ops/minio_tls_bootstrap.sh" + exit 1 + fi +} + +fail_if_insecure_env + echo "[1/4] Build and start production stack..." docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build diff --git a/scripts/ops/incident_checklist.sh b/scripts/ops/incident_checklist.sh new file mode 100755 index 0000000..a4c72e8 --- /dev/null +++ b/scripts/ops/incident_checklist.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +SEVERITY="MEDIUM" +CATEGORY="PDN_SUSPECTED" +SUMMARY="" +REQUEST_ID="" +TRACK_NUMBER="" +REPORTER="" +OUTPUT_FILE="" + +usage() { + cat < + --category + --summary + --request-id + --track-number + --reporter + --output Explicit output markdown file path + -h, --help + +Examples: + scripts/ops/incident_checklist.sh --severity HIGH --category UNAUTHORIZED_ACCESS --summary "Suspicious request card reads" +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --severity) SEVERITY="${2:-}"; shift 2 ;; + --category) CATEGORY="${2:-}"; shift 2 ;; + --summary) SUMMARY="${2:-}"; shift 2 ;; + --request-id) REQUEST_ID="${2:-}"; shift 2 ;; + --track-number) TRACK_NUMBER="${2:-}"; shift 2 ;; + --reporter) REPORTER="${2:-}"; shift 2 ;; + --output) OUTPUT_FILE="${2:-}"; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) echo "[ERROR] Unknown argument: $1" >&2; usage; exit 1 ;; + esac +done + +case "$(echo "$SEVERITY" | tr '[:lower:]' '[:upper:]')" in + LOW|MEDIUM|HIGH|CRITICAL) ;; + *) echo "[ERROR] Invalid severity: $SEVERITY" >&2; exit 1 ;; +esac +SEVERITY="$(echo "$SEVERITY" | tr '[:lower:]' '[:upper:]')" + +if [[ -z "$SUMMARY" ]]; then + SUMMARY="Initial triage started via incident checklist" +fi + +TS_UTC="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" +TS_FILE="$(date -u +"%Y%m%d-%H%M%S")" +HOSTNAME_VALUE="$(hostname)" + +if [[ -z "$OUTPUT_FILE" ]]; then + mkdir -p reports/incidents + OUTPUT_FILE="reports/incidents/incident-${TS_FILE}.md" +else + mkdir -p "$(dirname "$OUTPUT_FILE")" +fi + +BACKEND_HEALTH="unknown" +CHAT_HEALTH="unknown" +EMAIL_HEALTH="unknown" + +if curl -fsS http://localhost:8081/health >/dev/null 2>&1; then BACKEND_HEALTH="ok"; else BACKEND_HEALTH="failed"; fi +if curl -fsS http://localhost:8081/chat-health >/dev/null 2>&1; then CHAT_HEALTH="ok"; else CHAT_HEALTH="failed"; fi +if curl -fsS http://localhost:8081/email-health >/dev/null 2>&1; then EMAIL_HEALTH="ok"; else EMAIL_HEALTH="failed"; fi + +cat > "$OUTPUT_FILE" < reports/incidents/logs-${TS_FILE}.txt +~~~ +REPORT + +echo "[OK] Incident checklist created: $OUTPUT_FILE" diff --git a/scripts/ops/minio_tls_bootstrap.sh b/scripts/ops/minio_tls_bootstrap.sh new file mode 100755 index 0000000..6043d14 --- /dev/null +++ b/scripts/ops/minio_tls_bootstrap.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +OUT_DIR="${1:-$ROOT_DIR/deploy/tls/minio}" +CA_CN="${MINIO_TLS_CA_CN:-Law Internal MinIO CA}" +SERVER_CN="${MINIO_TLS_SERVER_CN:-minio}" +VALID_DAYS="${MINIO_TLS_VALID_DAYS:-825}" +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 + fi + done +fi + +if ! command -v openssl >/dev/null 2>&1; then + echo "[ERROR] openssl not found" >&2 + exit 1 +fi + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT + +cat > "$tmp_dir/server.cnf" </dev/null 2>&1 +openssl req -x509 -new -nodes -key "$OUT_DIR/ca.key" -sha256 -days 3650 \ + -out "$OUT_DIR/ca.crt" -subj "/CN=${CA_CN}" >/dev/null 2>&1 + +echo "[2/4] Generating MinIO server key + CSR..." +openssl genrsa -out "$OUT_DIR/private.key" 4096 >/dev/null 2>&1 +openssl req -new -key "$OUT_DIR/private.key" -out "$tmp_dir/server.csr" -config "$tmp_dir/server.cnf" >/dev/null 2>&1 + +echo "[3/4] Signing MinIO server certificate with internal CA..." +openssl x509 -req -in "$tmp_dir/server.csr" \ + -CA "$OUT_DIR/ca.crt" -CAkey "$OUT_DIR/ca.key" -CAcreateserial \ + -out "$tmp_dir/server.crt" -days "$VALID_DAYS" -sha256 \ + -extensions req_ext -extfile "$tmp_dir/server.cnf" >/dev/null 2>&1 + +cat "$tmp_dir/server.crt" "$OUT_DIR/ca.crt" > "$OUT_DIR/public.crt" + +chmod 600 "$OUT_DIR/ca.key" "$OUT_DIR/private.key" +chmod 644 "$OUT_DIR/ca.crt" "$OUT_DIR/public.crt" + +echo "[4/4] Done. Generated files:" +echo " - $OUT_DIR/ca.crt" +echo " - $OUT_DIR/ca.key" +echo " - $OUT_DIR/public.crt" +echo " - $OUT_DIR/private.key" +echo +echo "Use in production .env:" +echo " MINIO_TLS_ENABLED=true" +echo " S3_ENDPOINT=https://minio:9000" +echo " S3_USE_SSL=true" +echo " S3_VERIFY_SSL=true" +echo " S3_CA_CERT_PATH=/etc/ssl/minio/ca.crt" diff --git a/scripts/ops/prod_security_audit.sh b/scripts/ops/prod_security_audit.sh new file mode 100755 index 0000000..241d050 --- /dev/null +++ b/scripts/ops/prod_security_audit.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +DOMAIN="${DOMAIN:-ruakb.ru}" +WWW_DOMAIN="${WWW_DOMAIN:-www.ruakb.ru}" +SECOND_DOMAIN="${SECOND_DOMAIN:-ruakb.online}" +SECOND_WWW_DOMAIN="${SECOND_WWW_DOMAIN:-www.ruakb.online}" +LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-admin@ruakb.ru}" +AUTO_CERT_INIT="${AUTO_CERT_INIT:-0}" + +PROD_COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml) +CERT_COMPOSE=(docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml -f docker-compose.prod.cert.yml) + +log() { + echo "[SEC-AUDIT] $*" +} + +warn() { + echo "[SEC-AUDIT][WARN] $*" >&2 +} + +fail() { + echo "[SEC-AUDIT][ERROR] $*" >&2 + exit 1 +} + +file_missing() { + [[ ! -f "$1" ]] +} + +ensure_env_file() { + if [[ -f ".env" ]]; then + log ".env found" + return 0 + fi + + if [[ -f ".env.prod" ]]; then + cp .env.prod .env + chmod 600 .env + log ".env was missing -> restored from .env.prod" + return 0 + fi + + if [[ -f ".env.production" ]]; then + log ".env/.env.prod missing -> generating .env.prod from .env.production" + ./scripts/ops/rotate_prod_secrets.sh --env-in .env.production --env-out .env.prod + cp .env.prod .env + chmod 600 .env + log ".env created from generated .env.prod" + return 0 + fi + + fail "Cannot build .env automatically: missing both .env.prod and .env.production" +} + +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 + else + log "MinIO TLS bundle present" + fi +} + +ensure_compose_files() { + file_missing "docker-compose.prod.nginx.yml" && fail "Missing docker-compose.prod.nginx.yml" + file_missing "docker-compose.prod.cert.yml" && fail "Missing docker-compose.prod.cert.yml" + file_missing "frontend/nginx.prod.conf" && fail "Missing frontend/nginx.prod.conf" + file_missing "deploy/nginx/edge-http-only.conf" && fail "Missing deploy/nginx/edge-http-only.conf" + file_missing "deploy/nginx/edge-https.conf" && fail "Missing deploy/nginx/edge-https.conf" + log "Compose/nginx files present" +} + +stack_up_and_migrate() { + log "Starting production stack (nginx profile)" + "${PROD_COMPOSE[@]}" up -d --build --remove-orphans db redis minio backend chat-service email-service worker beat frontend edge clamav + + log "Applying migrations" + "${PROD_COMPOSE[@]}" exec -T backend alembic upgrade head +} + +run_security_preflight() { + log "Running production security preflight (app-level config validation)" + "${PROD_COMPOSE[@]}" run --rm --no-deps backend python - <<'PY' +from app.core.config import validate_production_security_or_raise +validate_production_security_or_raise("prod-security-audit") +print("production security config validation: ok") +PY +} + +run_local_smoke() { + log "Running local smoke checks via localhost" + ./scripts/ops/check_chat_health.sh http://localhost >/dev/null + ./scripts/ops/security_smoke.sh http://localhost >/dev/null + log "Local smoke checks passed" +} + +https_health_ok() { + local url="$1" + local code + code="$(curl -k -sS -o /dev/null -w "%{http_code}" "${url%/}/health" || true)" + [[ "$code" == "200" ]] +} + +cert_bootstrap() { + log "AUTO_CERT_INIT=1 and https health failed -> running cert bootstrap" + "${CERT_COMPOSE[@]}" up -d --build db redis minio backend chat-service email-service worker beat frontend edge + "${CERT_COMPOSE[@]}" run --rm certbot certonly --webroot -w /var/www/certbot \ + --email "$LETSENCRYPT_EMAIL" --agree-tos --no-eff-email --non-interactive --expand \ + -d "$DOMAIN" -d "$WWW_DOMAIN" -d "$SECOND_DOMAIN" -d "$SECOND_WWW_DOMAIN" + "${PROD_COMPOSE[@]}" up -d --build edge +} + +run_domain_smoke() { + local domain="$1" + [[ -z "$domain" ]] && return 0 + local url="https://${domain}" + + if ! https_health_ok "$url"; then + if [[ "$AUTO_CERT_INIT" == "1" ]]; then + cert_bootstrap + https_health_ok "$url" || fail "HTTPS health still failing after cert bootstrap: ${url}/health" + else + fail "HTTPS health check failed: ${url}/health (set AUTO_CERT_INIT=1 to auto-bootstrap certs)" + fi + fi + + log "Running security smoke for $url" + ./scripts/ops/security_smoke.sh "$url" >/dev/null +} + +run_incident_report() { + log "Generating incident checklist snapshot" + ./scripts/ops/incident_checklist.sh \ + --severity LOW \ + --category MONITORING_ALERT \ + --summary "Scheduled production security audit completed" +} + +print_summary() { + log "Collecting final status" + "${PROD_COMPOSE[@]}" ps + local latest_security_report + latest_security_report="$(ls -1t reports/security/security-smoke-*.md 2>/dev/null | head -n 1 || true)" + local latest_incident_report + latest_incident_report="$(ls -1t reports/incidents/incident-*.md 2>/dev/null | head -n 1 || true)" + [[ -n "$latest_security_report" ]] && log "Latest security smoke report: $latest_security_report" + [[ -n "$latest_incident_report" ]] && log "Latest incident checklist: $latest_incident_report" +} + +main() { + ensure_compose_files + ensure_env_file + ensure_minio_tls_bundle + + stack_up_and_migrate + run_security_preflight + run_local_smoke + run_domain_smoke "$DOMAIN" + run_domain_smoke "$SECOND_DOMAIN" + run_incident_report + print_summary + + log "Production security audit completed successfully" +} + +main "$@" diff --git a/scripts/ops/rotate_encryption_kid.sh b/scripts/ops/rotate_encryption_kid.sh new file mode 100755 index 0000000..64e5aa5 --- /dev/null +++ b/scripts/ops/rotate_encryption_kid.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE=".env" +KID="" +DATA_SECRET="" +CHAT_SECRET="" + +usage() { + cat < Env file to update (default: .env) + --kid KID to activate (default: kYYYYMMDDHHMM) + --data-secret DATA key secret (default: generated) + --chat-secret CHAT key secret (default: same as data secret) + -h, --help + +Result: + - Updates DATA_ENCRYPTION_KEYS / CHAT_ENCRYPTION_KEYS + - Sets DATA_ENCRYPTION_ACTIVE_KID / CHAT_ENCRYPTION_ACTIVE_KID + +After updating env: + 1) restart backend/chat/worker with new env + 2) run re-encryption: + docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-file) + ENV_FILE="${2:-}" + shift 2 + ;; + --kid) + KID="${2:-}" + shift 2 + ;; + --data-secret) + DATA_SECRET="${2:-}" + shift 2 + ;; + --chat-secret) + CHAT_SECRET="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[ERROR] Unknown option: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ ! -f "$ENV_FILE" ]]; then + echo "[ERROR] Env file not found: $ENV_FILE" >&2 + exit 1 +fi + +if ! command -v openssl >/dev/null 2>&1; then + echo "[ERROR] openssl not found" >&2 + exit 1 +fi + +if [[ -z "$KID" ]]; then + KID="k$(date -u +%Y%m%d%H%M)" +fi + +if [[ -z "$DATA_SECRET" ]]; then + DATA_SECRET="$(openssl rand -base64 64 | tr -d '\n' | tr '+/' 'AZ' | tr -dc 'A-Za-z0-9' | cut -c1-64)" +fi + +if [[ -z "$CHAT_SECRET" ]]; then + CHAT_SECRET="$DATA_SECRET" +fi + +read_env_var() { + local key="$1" + grep -E "^${key}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true +} + +upsert_env_var() { + local key="$1" + local value="$2" + local tmp + tmp="$(mktemp)" + awk -v k="$key" -v v="$value" ' + BEGIN { done = 0 } + $0 ~ ("^" k "=") { print k "=" v; done = 1; next } + { print } + END { if (!done) print k "=" v } + ' "$ENV_FILE" > "$tmp" + mv "$tmp" "$ENV_FILE" +} + +merge_kid_secret() { + local csv="$1" + local kid="$2" + local secret="$3" + local out="" + local found="0" + IFS=',' read -r -a parts <<< "$csv" + for part in "${parts[@]}"; do + local token key value + token="$(echo "$part" | xargs)" + [[ -z "$token" ]] && continue + if [[ "$token" == *=* ]]; then + key="${token%%=*}" + value="${token#*=}" + key="$(echo "$key" | xargs)" + value="$(echo "$value" | xargs)" + if [[ "$key" == "$kid" ]]; then + token="${kid}=${secret}" + found="1" + fi + fi + if [[ -n "$out" ]]; then + out+="${out:+,}${token}" + else + out="$token" + fi + done + if [[ "$found" != "1" ]]; then + if [[ -n "$out" ]]; then + out+=",${kid}=${secret}" + else + out="${kid}=${secret}" + fi + fi + echo "$out" +} + +current_data_keys="$(read_env_var DATA_ENCRYPTION_KEYS)" +current_chat_keys="$(read_env_var CHAT_ENCRYPTION_KEYS)" +new_data_keys="$(merge_kid_secret "$current_data_keys" "$KID" "$DATA_SECRET")" +new_chat_keys="$(merge_kid_secret "$current_chat_keys" "$KID" "$CHAT_SECRET")" + +upsert_env_var "DATA_ENCRYPTION_KEYS" "$new_data_keys" +upsert_env_var "CHAT_ENCRYPTION_KEYS" "$new_chat_keys" +upsert_env_var "DATA_ENCRYPTION_ACTIVE_KID" "$KID" +upsert_env_var "CHAT_ENCRYPTION_ACTIVE_KID" "$KID" + +echo "[OK] Updated $ENV_FILE" +echo " DATA_ENCRYPTION_ACTIVE_KID=$KID" +echo " CHAT_ENCRYPTION_ACTIVE_KID=$KID" +echo +echo "Next steps:" +echo " 1) restart backend/chat/worker with updated env" +echo " 2) re-encrypt historical data:" +echo " docker compose exec -T backend python -m app.scripts.reencrypt_with_active_kid --apply" diff --git a/scripts/ops/rotate_prod_secrets.sh b/scripts/ops/rotate_prod_secrets.sh new file mode 100755 index 0000000..7b11146 --- /dev/null +++ b/scripts/ops/rotate_prod_secrets.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +ENV_IN=".env.production" +ENV_OUT=".env.prod" +APPLY_RUNNING=0 +SKIP_DB_ROTATE=0 +SKIP_RESTART=0 +COMPOSE_OVERRIDE="docker-compose.prod.nginx.yml" +NON_INTERACTIVE=0 +REQUIRED_CONFIRM_TOKEN="ROTATE-PROD-SECRETS" +CONFIRM_TOKEN_INPUT="" + +usage() { + cat <<'EOF' +Usage: + scripts/ops/rotate_prod_secrets.sh [options] + +Options: + --env-in Source env template (default: .env.production) + --env-out Output env file with rotated secrets (default: .env.prod) + --compose-override Compose override for production apply (default: docker-compose.prod.nginx.yml) + --apply-running Apply generated env to running stack (.env replace + DB password rotate + recreate) + --non-interactive Disable prompt confirmation (requires valid --require-confirmation-token) + --require-confirmation-token + Mandatory token for --apply-running. Expected: ROTATE-PROD-SECRETS + --skip-db-rotate With --apply-running: do not run ALTER USER in Postgres + --skip-restart With --apply-running: do not recreate stack / migrate / health checks + -h, --help Show help + +Examples: + scripts/ops/rotate_prod_secrets.sh + scripts/ops/rotate_prod_secrets.sh --apply-running + scripts/ops/rotate_prod_secrets.sh --env-in .env --env-out .env.prod --apply-running +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --env-in) + ENV_IN="${2:-}" + shift 2 + ;; + --env-out) + ENV_OUT="${2:-}" + shift 2 + ;; + --compose-override) + COMPOSE_OVERRIDE="${2:-}" + shift 2 + ;; + --apply-running) + APPLY_RUNNING=1 + shift + ;; + --non-interactive) + NON_INTERACTIVE=1 + shift + ;; + --require-confirmation-token) + CONFIRM_TOKEN_INPUT="${2:-}" + shift 2 + ;; + --skip-db-rotate) + SKIP_DB_ROTATE=1 + shift + ;; + --skip-restart) + SKIP_RESTART=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "[ERROR] Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ ! -f "$ENV_IN" ]]; then + echo "[ERROR] Input env file not found: $ENV_IN" >&2 + exit 1 +fi + +if ! command -v openssl >/dev/null 2>&1; then + echo "[ERROR] openssl not found (required for secret generation)" >&2 + exit 1 +fi + +COMPOSE_ARGS=(-f docker-compose.yml -f "$COMPOSE_OVERRIDE") +if [[ "$APPLY_RUNNING" -eq 1 && ! -f "$COMPOSE_OVERRIDE" ]]; then + echo "[ERROR] Compose override file not found: $COMPOSE_OVERRIDE" >&2 + exit 1 +fi + +rand_alnum() { + local length="${1:-64}" + local bytes=$(( (length + 1) / 2 )) + openssl rand -hex "$bytes" | cut -c1-"$length" +} + +rand_secret() { + local length="${1:-64}" + local out + out="$(openssl rand -base64 "$length" | tr -d '\n' | tr '+/' 'AZ' | tr -dc 'A-Za-z0-9' | cut -c1-"$length")" + if [[ "${#out}" -lt "$length" ]]; then + out="${out}$(rand_alnum "$((length - ${#out}))")" + fi + echo "$out" +} + +read_env_value() { + local key="$1" + local file="$2" + local value + value="$(grep -E "^${key}=" "$file" | tail -n1 | cut -d= -f2- || true)" + echo "$value" +} + +upsert_env_value() { + local key="$1" + local value="$2" + local file="$3" + local tmp + tmp="$(mktemp)" + awk -v k="$key" -v v="$value" ' + BEGIN { done = 0 } + $0 ~ ("^" k "=") { print k "=" v; done = 1; next } + { print } + END { + if (!done) print k "=" v + } + ' "$file" > "$tmp" + mv "$tmp" "$file" +} + +db_url_with_password() { + local current_url="$1" + local user="$2" + local pass="$3" + local db_name="$4" + if [[ -n "$current_url" && "$current_url" =~ ^([^:]+://[^:]+:)[^@]*(@.*)$ ]]; then + echo "${BASH_REMATCH[1]}${pass}${BASH_REMATCH[2]}" + return 0 + fi + echo "postgresql+psycopg://${user}:${pass}@db:5432/${db_name}" +} + +echo "[1/5] Preparing output env file..." +cp "$ENV_IN" "$ENV_OUT" +chmod 600 "$ENV_OUT" + +NEW_ADMIN_JWT_SECRET="$(rand_secret 64)" +NEW_PUBLIC_JWT_SECRET="$(rand_secret 64)" +NEW_DATA_ENCRYPTION_SECRET="$(rand_secret 64)" +NEW_CHAT_ENCRYPTION_SECRET="$(rand_secret 64)" +NEW_ENC_KID="k$(date -u +%Y%m%d%H%M)" +NEW_INTERNAL_SERVICE_TOKEN="$(rand_secret 64)" +NEW_POSTGRES_PASSWORD="$(rand_secret 40)" +NEW_MINIO_ROOT_USER="minio_$(rand_alnum 14 | tr '[:upper:]' '[:lower:]')" +NEW_MINIO_ROOT_PASSWORD="$(rand_secret 48)" +NEW_S3_ACCESS_KEY="$(rand_alnum 20)" +NEW_S3_SECRET_KEY="$(rand_secret 48)" +NEW_BOOTSTRAP_PASSWORD="$(rand_secret 32)" + +POSTGRES_USER_VALUE="$(read_env_value "POSTGRES_USER" "$ENV_OUT")" +POSTGRES_DB_VALUE="$(read_env_value "POSTGRES_DB" "$ENV_OUT")" +DATABASE_URL_VALUE="$(read_env_value "DATABASE_URL" "$ENV_OUT")" + +if [[ -z "$POSTGRES_USER_VALUE" ]]; then + POSTGRES_USER_VALUE="postgres" +fi +if [[ -z "$POSTGRES_DB_VALUE" ]]; then + POSTGRES_DB_VALUE="legal" +fi + +NEW_DATABASE_URL="$(db_url_with_password "$DATABASE_URL_VALUE" "$POSTGRES_USER_VALUE" "$NEW_POSTGRES_PASSWORD" "$POSTGRES_DB_VALUE")" + +echo "[2/5] Writing rotated internal secrets into $ENV_OUT..." +upsert_env_value "APP_ENV" "prod" "$ENV_OUT" +upsert_env_value "PRODUCTION_ENFORCE_SECURE_SETTINGS" "true" "$ENV_OUT" +upsert_env_value "OTP_DEV_MODE" "false" "$ENV_OUT" +upsert_env_value "ADMIN_BOOTSTRAP_ENABLED" "false" "$ENV_OUT" +upsert_env_value "PUBLIC_COOKIE_SECURE" "true" "$ENV_OUT" +upsert_env_value "S3_USE_SSL" "true" "$ENV_OUT" +upsert_env_value "S3_VERIFY_SSL" "true" "$ENV_OUT" +upsert_env_value "S3_CA_CERT_PATH" "/etc/ssl/minio/ca.crt" "$ENV_OUT" +upsert_env_value "MINIO_TLS_ENABLED" "true" "$ENV_OUT" +upsert_env_value "PUBLIC_STRICT_ORIGIN_CHECK" "true" "$ENV_OUT" +upsert_env_value "CORS_ALLOW_METHODS" "GET,POST,PUT,PATCH,DELETE,OPTIONS" "$ENV_OUT" +upsert_env_value "CORS_ALLOW_HEADERS" "Authorization,Content-Type,X-Requested-With,X-Request-ID" "$ENV_OUT" +upsert_env_value "CORS_ALLOW_CREDENTIALS" "true" "$ENV_OUT" +upsert_env_value "ADMIN_AUTH_MODE" "password_totp_required" "$ENV_OUT" + +upsert_env_value "ADMIN_JWT_SECRET" "$NEW_ADMIN_JWT_SECRET" "$ENV_OUT" +upsert_env_value "PUBLIC_JWT_SECRET" "$NEW_PUBLIC_JWT_SECRET" "$ENV_OUT" +upsert_env_value "DATA_ENCRYPTION_SECRET" "$NEW_DATA_ENCRYPTION_SECRET" "$ENV_OUT" +upsert_env_value "CHAT_ENCRYPTION_SECRET" "$NEW_CHAT_ENCRYPTION_SECRET" "$ENV_OUT" +upsert_env_value "DATA_ENCRYPTION_ACTIVE_KID" "$NEW_ENC_KID" "$ENV_OUT" +upsert_env_value "CHAT_ENCRYPTION_ACTIVE_KID" "$NEW_ENC_KID" "$ENV_OUT" +upsert_env_value "DATA_ENCRYPTION_KEYS" "${NEW_ENC_KID}=${NEW_DATA_ENCRYPTION_SECRET}" "$ENV_OUT" +upsert_env_value "CHAT_ENCRYPTION_KEYS" "${NEW_ENC_KID}=${NEW_CHAT_ENCRYPTION_SECRET}" "$ENV_OUT" +upsert_env_value "INTERNAL_SERVICE_TOKEN" "$NEW_INTERNAL_SERVICE_TOKEN" "$ENV_OUT" + +upsert_env_value "POSTGRES_PASSWORD" "$NEW_POSTGRES_PASSWORD" "$ENV_OUT" +upsert_env_value "DATABASE_URL" "$NEW_DATABASE_URL" "$ENV_OUT" + +upsert_env_value "MINIO_ROOT_USER" "$NEW_MINIO_ROOT_USER" "$ENV_OUT" +upsert_env_value "MINIO_ROOT_PASSWORD" "$NEW_MINIO_ROOT_PASSWORD" "$ENV_OUT" +upsert_env_value "S3_ACCESS_KEY" "$NEW_S3_ACCESS_KEY" "$ENV_OUT" +upsert_env_value "S3_SECRET_KEY" "$NEW_S3_SECRET_KEY" "$ENV_OUT" +upsert_env_value "ADMIN_BOOTSTRAP_PASSWORD" "$NEW_BOOTSTRAP_PASSWORD" "$ENV_OUT" + +if [[ "$APPLY_RUNNING" -eq 0 ]]; then + echo "[3/5] Completed in prepare mode." + echo "Generated file: $ENV_OUT" + echo "Next step: run with --apply-running to update live stack." + exit 0 +fi + +if [[ ! -f ".env" ]]; then + echo "[ERROR] .env not found for --apply-running mode" >&2 + exit 1 +fi + +if [[ "$CONFIRM_TOKEN_INPUT" != "$REQUIRED_CONFIRM_TOKEN" ]]; then + echo "[ERROR] Invalid or missing confirmation token." >&2 + echo "Pass: --require-confirmation-token $REQUIRED_CONFIRM_TOKEN" >&2 + exit 1 +fi + +if [[ "$NON_INTERACTIVE" -eq 0 ]]; then + echo "WARNING: applying rotated secrets to running production stack." + echo "This will recreate services and invalidate active auth sessions." + read -r -p "Type $REQUIRED_CONFIRM_TOKEN to continue: " typed_token + if [[ "$typed_token" != "$REQUIRED_CONFIRM_TOKEN" ]]; then + echo "[ABORT] Confirmation token mismatch." >&2 + exit 1 + fi +fi + +TIMESTAMP="$(date +%Y%m%d-%H%M%S)" +BACKUP_FILE=".env.backup.${TIMESTAMP}" + +echo "[3/5] Backing up and activating new .env..." +cp .env "$BACKUP_FILE" +chmod 600 "$BACKUP_FILE" +cp "$ENV_OUT" .env +chmod 600 .env + +if [[ "$SKIP_DB_ROTATE" -eq 0 ]]; then + echo "[4/5] Rotating Postgres user password inside DB..." + docker compose "${COMPOSE_ARGS[@]}" up -d db >/dev/null + docker compose "${COMPOSE_ARGS[@]}" exec -T db \ + psql -U "$POSTGRES_USER_VALUE" -d postgres \ + -c "ALTER USER \"$POSTGRES_USER_VALUE\" WITH PASSWORD '$NEW_POSTGRES_PASSWORD';" +else + echo "[4/5] Skipped DB password ALTER USER (--skip-db-rotate)." +fi + +if [[ "$SKIP_RESTART" -eq 0 ]]; then + echo "[5/5] Recreating stack, applying migrations, and checking health..." + docker compose "${COMPOSE_ARGS[@]}" up -d --build --force-recreate --remove-orphans + docker compose "${COMPOSE_ARGS[@]}" exec -T backend alembic upgrade head + curl -fsS http://localhost/health >/dev/null + curl -fsS http://localhost/chat-health >/dev/null + curl -fsS http://localhost/email-health >/dev/null +else + echo "[5/5] Skipped stack restart/migrations/health checks (--skip-restart)." +fi + +echo "Rotation completed." +echo "Backup file: $BACKUP_FILE" +echo "Active env: .env" +echo "Generated env snapshot: $ENV_OUT" diff --git a/scripts/ops/security_smoke.sh b/scripts/ops/security_smoke.sh new file mode 100755 index 0000000..1500a12 --- /dev/null +++ b/scripts/ops/security_smoke.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +set -u + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +BASE_URL="${1:-http://localhost:8081}" +REPORT_DIR="${REPORT_DIR:-reports/security}" +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" + +mkdir -p "$REPORT_DIR" + +failures=() +warnings=() +passes=() + +add_pass() { passes+=("$1"); } +add_warn() { warnings+=("$1"); } +add_fail() { failures+=("$1"); } + +lower() { + echo "$1" | tr '[:upper:]' '[:lower:]' +} + +read_env_var() { + local key="$1" + if [[ ! -f ".env" ]]; then + echo "" + return 0 + fi + grep -E "^${key}=" .env | tail -n1 | cut -d= -f2- || true +} + +is_truthy() { + local value + value="$(lower "${1:-}")" + [[ "$value" == "true" || "$value" == "1" || "$value" == "yes" || "$value" == "on" ]] +} + +http_status_ok() { + local url="$1" + local code + code="$(curl -k -sS -o /dev/null -w "%{http_code}" "$url" || true)" + [[ "$code" == "200" ]] +} + +check_required_headers() { + local url="$1" + local head + head="$(curl -k -sS -I "$url" || true)" + local normalized + normalized="$(echo "$head" | tr -d '\r' | tr '[:upper:]' '[:lower:]')" + + if [[ "$normalized" == *"x-content-type-options: nosniff"* ]]; then + add_pass "header: X-Content-Type-Options=nosniff" + else + add_fail "missing/invalid header X-Content-Type-Options at ${url}" + fi + if [[ "$normalized" == *"x-frame-options:"* ]]; then + add_pass "header: X-Frame-Options present" + else + add_fail "missing header X-Frame-Options at ${url}" + fi + if [[ "$normalized" == *"referrer-policy:"* ]]; then + add_pass "header: Referrer-Policy present" + else + add_fail "missing header Referrer-Policy at ${url}" + fi + if [[ "$normalized" == *"content-security-policy:"* ]]; then + add_pass "header: Content-Security-Policy present" + else + add_fail "missing header Content-Security-Policy at ${url}" + fi +} + +check_tls_cert() { + local url="$1" + if [[ "$url" != https://* ]]; then + add_warn "tls check skipped (BASE_URL is not https): ${url}" + return 0 + fi + + local hostport host port cert not_after + hostport="${url#https://}" + hostport="${hostport%%/*}" + host="${hostport%%:*}" + port="443" + if [[ "$hostport" == *:* ]]; then + port="${hostport##*:}" + fi + + cert="$(echo | openssl s_client -connect "${host}:${port}" -servername "${host}" 2>/dev/null || true)" + if [[ "$cert" != *"BEGIN CERTIFICATE"* ]]; then + add_fail "tls certificate is not available for ${host}:${port}" + return 1 + fi + + not_after="$(echo "$cert" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2- || true)" + if [[ -z "$not_after" ]]; then + add_fail "tls certificate enddate cannot be read for ${host}:${port}" + return 1 + fi + + local days_left + days_left="$(python3 - </dev/null 2>&1; then + add_warn "docker is not available: service checks skipped" + return 0 + fi + local running + running="$(docker compose ps --services --filter status=running 2>/dev/null || true)" + if echo "$running" | grep -qx "$service"; then + add_pass "service running: ${service}" + return 0 + fi + add_fail "service is not running: ${service}" + return 1 +} + +check_db_security_audit_table() { + if ! command -v docker >/dev/null 2>&1; then + add_warn "docker is not available: DB checks skipped" + return 0 + fi + if [[ ! -f ".env" ]]; then + add_warn ".env not found: DB checks skipped" + return 0 + fi + + local pg_user pg_db + pg_user="$(read_env_var POSTGRES_USER)" + pg_db="$(read_env_var POSTGRES_DB)" + pg_user="${pg_user:-postgres}" + pg_db="${pg_db:-legal}" + + local exists + exists="$(docker compose exec -T db psql -U "$pg_user" -d "$pg_db" -Atc "select to_regclass('public.security_audit_log') is not null;" 2>/dev/null || true)" + if [[ "$exists" == "t" ]]; then + add_pass "db table exists: security_audit_log" + else + add_fail "db table missing or inaccessible: security_audit_log" + return 1 + fi + + local recent + recent="$(docker compose exec -T db psql -U "$pg_user" -d "$pg_db" -Atc "select count(*) from security_audit_log where created_at >= now() - interval '7 days';" 2>/dev/null || true)" + if [[ "$recent" =~ ^[0-9]+$ ]]; then + add_pass "db access: security_audit_log query ok (rows_7d=${recent})" + else + add_fail "db access error: cannot query security_audit_log" + return 1 + fi +} + +check_attachment_scan_availability() { + local scan_enabled clam_enabled + scan_enabled="$(read_env_var ATTACHMENT_SCAN_ENABLED)" + clam_enabled="$(read_env_var CLAMAV_ENABLED)" + + if is_truthy "$scan_enabled" || is_truthy "$clam_enabled"; then + check_compose_service_running "clamav" + else + add_warn "attachment scan disabled by config (ATTACHMENT_SCAN_ENABLED/CLAMAV_ENABLED)" + fi +} + +run_smoke() { + local health_url chat_health_url email_health_url + health_url="${BASE_URL%/}/health" + chat_health_url="${BASE_URL%/}/chat-health" + email_health_url="${BASE_URL%/}/email-health" + + if http_status_ok "$health_url"; then + add_pass "http 200: ${health_url}" + else + add_fail "http check failed: ${health_url}" + fi + if http_status_ok "$chat_health_url"; then + add_pass "http 200: ${chat_health_url}" + else + add_fail "http check failed: ${chat_health_url}" + fi + if http_status_ok "$email_health_url"; then + add_pass "http 200: ${email_health_url}" + else + add_fail "http check failed: ${email_health_url}" + fi + + check_required_headers "$health_url" + check_tls_cert "$BASE_URL" + check_cookie_and_security_flags + check_attachment_scan_availability + check_db_security_audit_table +} + +write_report() { + { + echo "# Security Smoke Report" + echo + echo "- Timestamp: ${TS_HUMAN}" + echo "- Base URL: ${BASE_URL}" + echo "- Result: $([[ ${#failures[@]} -eq 0 ]] && echo "PASS" || echo "FAIL")" + echo + echo "## Passed checks (${#passes[@]})" + for item in "${passes[@]}"; do + echo "- [x] ${item}" + done + echo + echo "## Warnings (${#warnings[@]})" + if [[ ${#warnings[@]} -eq 0 ]]; then + echo "- none" + else + for item in "${warnings[@]}"; do + echo "- [!] ${item}" + done + fi + echo + echo "## Failures (${#failures[@]})" + if [[ ${#failures[@]} -eq 0 ]]; then + echo "- none" + else + for item in "${failures[@]}"; do + echo "- [ ] ${item}" + done + fi + } > "$REPORT_FILE" +} + +run_smoke +write_report + +if [[ ${#failures[@]} -gt 0 ]]; then + echo "[ALERT] Security smoke failed (${#failures[@]} failure(s)). Report: ${REPORT_FILE}" >&2 + exit 2 +fi + +echo "[OK] Security smoke passed. Report: ${REPORT_FILE}" +exit 0 diff --git a/tests/test_crypto_kid_rotation.py b/tests/test_crypto_kid_rotation.py new file mode 100644 index 0000000..e85f872 --- /dev/null +++ b/tests/test_crypto_kid_rotation.py @@ -0,0 +1,115 @@ +import base64 +import hashlib +import hmac +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.core.config import settings +from app.services.chat_crypto import decrypt_message_body, encrypt_message_body, extract_message_kid +from app.services.invoice_crypto import ( + active_requisites_kid, + decrypt_requisites, + encrypt_requisites, + extract_requisites_kid, +) + + +def _xor_bytes(a: bytes, b: bytes) -> bytes: + return bytes(x ^ y for x, y in zip(a, b)) + + +def _legacy_invoice_token(payload: dict, secret: str) -> str: + raw = (str(payload).replace("'", '"')).encode("utf-8") + # stable json-like payload for this test suite + raw = b'{"secret":"LEGACY"}' if payload.get("secret") == "LEGACY" else raw + key = hashlib.sha256(secret.encode("utf-8")).digest() + nonce = bytes.fromhex("00112233445566778899aabbccddeeff") + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw)) + cipher = _xor_bytes(raw, stream) + tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest() + token = b"v1" + nonce + tag + cipher + return base64.urlsafe_b64encode(token).decode("ascii") + + +def _legacy_chat_token(plaintext: str, secret: str) -> str: + raw = plaintext.encode("utf-8") + key = hashlib.sha256(secret.encode("utf-8")).digest() + nonce = bytes.fromhex("ffeeddccbbaa99887766554433221100") + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw)) + cipher = _xor_bytes(raw, stream) + tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest() + token = b"v1" + nonce + tag + cipher + return "chatenc:v1:" + base64.urlsafe_b64encode(token).decode("ascii") + + +class CryptoKidRotationTests(unittest.TestCase): + def setUp(self): + self._backup = { + "DATA_ENCRYPTION_SECRET": settings.DATA_ENCRYPTION_SECRET, + "DATA_ENCRYPTION_ACTIVE_KID": settings.DATA_ENCRYPTION_ACTIVE_KID, + "DATA_ENCRYPTION_KEYS": settings.DATA_ENCRYPTION_KEYS, + "CHAT_ENCRYPTION_SECRET": settings.CHAT_ENCRYPTION_SECRET, + "CHAT_ENCRYPTION_ACTIVE_KID": settings.CHAT_ENCRYPTION_ACTIVE_KID, + "CHAT_ENCRYPTION_KEYS": settings.CHAT_ENCRYPTION_KEYS, + } + + def tearDown(self): + for key, value in self._backup.items(): + setattr(settings, key, value) + + def test_invoice_encrypt_uses_active_kid(self): + settings.DATA_ENCRYPTION_SECRET = "legacy-secret-1234567890" + settings.DATA_ENCRYPTION_ACTIVE_KID = "k2" + settings.DATA_ENCRYPTION_KEYS = "k1=old-secret-1111111111111111,k2=new-secret-2222222222222222" + + token = encrypt_requisites({"inn": "7700000000"}) + self.assertTrue(token.startswith("invenc:v2:")) + self.assertEqual(extract_requisites_kid(token), "k2") + payload = decrypt_requisites(token) + self.assertEqual(payload.get("inn"), "7700000000") + self.assertEqual(active_requisites_kid(), "k2") + + def test_invoice_decrypts_legacy_after_rotation(self): + legacy_secret = "legacy-data-secret-aaaaaaaaaaaaaaaa" + legacy = _legacy_invoice_token({"secret": "LEGACY"}, legacy_secret) + + settings.DATA_ENCRYPTION_SECRET = "" + settings.DATA_ENCRYPTION_ACTIVE_KID = "k2" + settings.DATA_ENCRYPTION_KEYS = f"k1={legacy_secret},k2=new-data-secret-bbbbbbbbbbbbbbbb" + + payload = decrypt_requisites(legacy) + self.assertEqual(payload.get("secret"), "LEGACY") + + rotated = encrypt_requisites(payload) + self.assertEqual(extract_requisites_kid(rotated), "k2") + self.assertEqual(decrypt_requisites(rotated).get("secret"), "LEGACY") + + def test_chat_decrypts_legacy_and_writes_new_kid(self): + legacy_secret = "legacy-chat-secret-aaaaaaaaaaaaaaaa" + legacy_token = _legacy_chat_token("legacy message", legacy_secret) + + settings.DATA_ENCRYPTION_SECRET = "" + settings.DATA_ENCRYPTION_ACTIVE_KID = "k2" + settings.DATA_ENCRYPTION_KEYS = "k2=new-data-secret-bbbbbbbbbbbbbbbb" + settings.CHAT_ENCRYPTION_SECRET = "" + settings.CHAT_ENCRYPTION_ACTIVE_KID = "k2" + settings.CHAT_ENCRYPTION_KEYS = f"k1={legacy_secret},k2=new-chat-secret-cccccccccccccccc" + + plain = decrypt_message_body(legacy_token) + self.assertEqual(plain, "legacy message") + + token = encrypt_message_body(plain) + self.assertTrue(token.startswith("chatenc:v2:")) + self.assertEqual(extract_message_kid(token), "k2") + self.assertEqual(decrypt_message_body(token), "legacy message") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_http_hardening.py b/tests/test_http_hardening.py index 40db980..8b25e24 100644 --- a/tests/test_http_hardening.py +++ b/tests/test_http_hardening.py @@ -92,4 +92,7 @@ class HttpHardeningTests(unittest.TestCase): } headers = _response_security_headers(Request(scope)) self.assertEqual(headers.get("X-Frame-Options"), "DENY") - self.assertIn("frame-ancestors 'none'", str(headers.get("Content-Security-Policy"))) + csp = str(headers.get("Content-Security-Policy")) + self.assertIn("frame-ancestors 'none'", csp) + self.assertIn("script-src 'self'", csp) + self.assertIn("connect-src 'self'", csp) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 88c221a..59e0953 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -105,6 +105,7 @@ class MigrationTests(unittest.TestCase): "notifications", "invoices", "security_audit_log", + "data_retention_policies", "alembic_version", } tables = set(self.inspector.get_table_names()) @@ -113,7 +114,7 @@ class MigrationTests(unittest.TestCase): def test_alembic_version_is_set(self): with self.engine.connect() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() - self.assertEqual(version, "0030_attachment_scan") + self.assertEqual(version, "0031_pii_retention_and_consent") def test_responsible_column_exists_in_all_domain_tables(self): tables = { @@ -143,6 +144,7 @@ class MigrationTests(unittest.TestCase): "notifications", "invoices", "security_audit_log", + "data_retention_policies", } for table in tables: columns = {column["name"] for column in self.inspector.get_columns(table)} @@ -193,6 +195,9 @@ class MigrationTests(unittest.TestCase): columns = {column["name"] for column in self.inspector.get_columns("requests")} self.assertIn("client_id", columns) self.assertIn("client_email", columns) + self.assertIn("pdn_consent", columns) + self.assertIn("pdn_consent_at", columns) + self.assertIn("pdn_consent_ip", columns) self.assertIn("important_date_at", columns) self.assertIn("effective_rate", columns) self.assertIn("request_cost", columns) @@ -200,6 +205,17 @@ class MigrationTests(unittest.TestCase): self.assertIn("paid_at", columns) self.assertIn("paid_by_admin_id", columns) + def test_data_retention_policies_contains_core_columns(self): + columns = {column["name"] for column in self.inspector.get_columns("data_retention_policies")} + self.assertIn("id", columns) + self.assertIn("entity", columns) + self.assertIn("retention_days", columns) + self.assertIn("enabled", columns) + self.assertIn("hard_delete", columns) + self.assertIn("description", columns) + self.assertIn("created_at", columns) + self.assertIn("responsible", columns) + def test_status_history_contains_important_date_column(self): columns = {column["name"] for column in self.inspector.get_columns("status_history")} self.assertIn("important_date_at", columns) diff --git a/tests/test_origin_guard.py b/tests/test_origin_guard.py new file mode 100644 index 0000000..a87c13a --- /dev/null +++ b/tests/test_origin_guard.py @@ -0,0 +1,81 @@ +import os +import unittest + +from fastapi import HTTPException +from starlette.requests import Request + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.core.config import settings +from app.services.origin_guard import enforce_public_origin_or_403 + + +def _request_with_headers(headers: dict[str, str]) -> Request: + raw_headers = [(str(k).lower().encode("latin-1"), str(v).encode("latin-1")) for k, v in headers.items()] + scope = { + "type": "http", + "http_version": "1.1", + "method": "POST", + "scheme": "https", + "path": "/api/public/otp/send", + "query_string": b"", + "headers": raw_headers, + "client": ("127.0.0.1", 52000), + "server": ("testserver", 443), + } + return Request(scope) + + +class OriginGuardTests(unittest.TestCase): + def setUp(self): + self._backup = { + "APP_ENV": settings.APP_ENV, + "PUBLIC_STRICT_ORIGIN_CHECK": settings.PUBLIC_STRICT_ORIGIN_CHECK, + "PUBLIC_ALLOWED_WEB_ORIGINS": settings.PUBLIC_ALLOWED_WEB_ORIGINS, + } + settings.APP_ENV = "production" + settings.PUBLIC_STRICT_ORIGIN_CHECK = True + settings.PUBLIC_ALLOWED_WEB_ORIGINS = "https://ruakb.ru,https://www.ruakb.ru" + + def tearDown(self): + for key, value in self._backup.items(): + setattr(settings, key, value) + + def test_allows_whitelisted_origin(self): + request = _request_with_headers({"origin": "https://ruakb.ru"}) + enforce_public_origin_or_403(request, endpoint="/api/public/otp/send") + + def test_rejects_missing_origin_and_referer(self): + request = _request_with_headers({}) + with self.assertRaises(HTTPException) as exc: + enforce_public_origin_or_403(request, endpoint="/api/public/otp/send") + self.assertEqual(exc.exception.status_code, 403) + + def test_rejects_cross_site_fetch_metadata(self): + request = _request_with_headers( + { + "origin": "https://ruakb.ru", + "sec-fetch-site": "cross-site", + } + ) + with self.assertRaises(HTTPException) as exc: + enforce_public_origin_or_403(request, endpoint="/api/public/otp/send") + self.assertEqual(exc.exception.status_code, 403) + + def test_allows_referer_when_origin_missing(self): + request = _request_with_headers({"referer": "https://www.ruakb.ru/landing"}) + enforce_public_origin_or_403(request, endpoint="/api/public/otp/send") + + def test_can_disable_check(self): + settings.PUBLIC_STRICT_ORIGIN_CHECK = False + request = _request_with_headers({}) + enforce_public_origin_or_403(request, endpoint="/api/public/otp/send") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_cabinet.py b/tests/test_public_cabinet.py index 325b18e..85ea54c 100644 --- a/tests/test_public_cabinet.py +++ b/tests/test_public_cabinet.py @@ -277,7 +277,7 @@ class PublicCabinetTests(unittest.TestCase): with self.SessionLocal() as db: raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one() - self.assertTrue(str(raw_encrypted).startswith("chatenc:v1:")) + self.assertTrue(str(raw_encrypted).startswith("chatenc:")) self.assertNotEqual(str(raw_encrypted), payload_body) listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies) diff --git a/tests/test_public_requests.py b/tests/test_public_requests.py index 95c2557..08e9a1b 100644 --- a/tests/test_public_requests.py +++ b/tests/test_public_requests.py @@ -122,6 +122,7 @@ class PublicRequestCreateTests(unittest.TestCase): "topic_code": "consulting", "description": "Тестируем создание заявки", "extra_fields": {"referral_name": "Партнер"}, + "pdn_consent": True, } response = self.client.post("/api/public/requests", json=payload) self.assertEqual(response.status_code, 401) @@ -147,6 +148,9 @@ class PublicRequestCreateTests(unittest.TestCase): self.assertEqual(created.status_code, "NEW") self.assertEqual(created.track_number, body["track_number"]) self.assertEqual(created.responsible, "Клиент") + self.assertTrue(created.pdn_consent) + self.assertIsNotNone(created.pdn_consent_at) + self.assertIsNotNone(created.pdn_consent_ip) client = db.get(Client, created.client_id) self.assertIsNotNone(client) self.assertEqual(client.phone, payload["client_phone"]) @@ -201,6 +205,47 @@ class PublicRequestCreateTests(unittest.TestCase): denied_other_track = self.client.get("/api/public/requests/TRK-OTHER") self.assertEqual(denied_other_track.status_code, 403) + def test_otp_send_rejects_honeypot_field(self): + response = self.client.post( + "/api/public/otp/send", + json={ + "purpose": "CREATE_REQUEST", + "client_phone": self._unique_phone(), + "hp_field": "https://spam.example", + }, + ) + self.assertEqual(response.status_code, 400) + + def test_create_request_rejects_honeypot_field(self): + phone = self._unique_phone() + self._send_and_verify_create_otp(phone) + payload = { + "client_name": "Клиент honeypot", + "client_phone": phone, + "topic_code": "consulting", + "description": "Проверка honeypot", + "extra_fields": {}, + "pdn_consent": True, + "hp_field": "https://spam.example", + } + response = self.client.post("/api/public/requests", json=payload) + self.assertEqual(response.status_code, 400) + + def test_create_request_requires_pdn_consent(self): + phone = self._unique_phone() + self._send_and_verify_create_otp(phone) + payload = { + "client_name": "Клиент без согласия", + "client_phone": phone, + "topic_code": "consulting", + "description": "Проверка согласия ПДн", + "extra_fields": {}, + "pdn_consent": False, + } + response = self.client.post("/api/public/requests", json=payload) + self.assertEqual(response.status_code, 400) + self.assertIn("согласие", str(response.json().get("detail", "")).lower()) + def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self): phone = "+79996660077" with self.SessionLocal() as db: @@ -299,6 +344,7 @@ class PublicRequestCreateTests(unittest.TestCase): "topic_code": "consulting", "description": "Email auth mode create", "extra_fields": {}, + "pdn_consent": True, }, ) self.assertEqual(create.status_code, 201) @@ -427,6 +473,7 @@ class PublicRequestCreateTests(unittest.TestCase): "topic_code": "consulting", "description": "Проверка обязательного поля", "extra_fields": {}, + "pdn_consent": True, }, ) self.assertEqual(missing.status_code, 400) @@ -440,6 +487,7 @@ class PublicRequestCreateTests(unittest.TestCase): "topic_code": "consulting", "description": "Проверка обязательного поля", "extra_fields": {"passport_series": "1234"}, + "pdn_consent": True, }, ) self.assertEqual(created.status_code, 201) @@ -475,6 +523,32 @@ class PublicRequestCreateTests(unittest.TestCase): self.assertIn(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header) self.assertIn("httponly", cookie_header.lower()) + def test_verify_otp_respects_cookie_security_flags_from_settings(self): + phone = self._unique_phone() + secure_backup = settings.PUBLIC_COOKIE_SECURE + samesite_backup = settings.PUBLIC_COOKIE_SAMESITE + try: + settings.PUBLIC_COOKIE_SECURE = True + settings.PUBLIC_COOKIE_SAMESITE = "strict" + with patch("app.api.public.otp._generate_code", return_value="313131"): + sent = self.client.post( + "/api/public/otp/send", + json={"purpose": "CREATE_REQUEST", "client_phone": phone}, + ) + self.assertEqual(sent.status_code, 200) + + verified = self.client.post( + "/api/public/otp/verify", + json={"purpose": "CREATE_REQUEST", "client_phone": phone, "code": "313131"}, + ) + self.assertEqual(verified.status_code, 200) + cookie_header = str(verified.headers.get("set-cookie") or "") + self.assertIn("Secure", cookie_header) + self.assertIn("SameSite=strict", cookie_header) + finally: + settings.PUBLIC_COOKIE_SECURE = secure_backup + settings.PUBLIC_COOKIE_SAMESITE = samesite_backup + def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self): phone = "+79998887766" with self.SessionLocal() as db: diff --git a/tests/test_reencrypt_with_active_kid.py b/tests/test_reencrypt_with_active_kid.py new file mode 100644 index 0000000..877886c --- /dev/null +++ b/tests/test_reencrypt_with_active_kid.py @@ -0,0 +1,187 @@ +import base64 +import hashlib +import hmac +import os +import unittest +from datetime import datetime, timezone +from uuid import uuid4 + +from sqlalchemy import create_engine, delete, text +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.core.config import settings +from app.models.admin_user import AdminUser +from app.models.invoice import Invoice +from app.models.message import Message +from app.models.request import Request +from app.scripts import reencrypt_with_active_kid as reencrypt_script +from app.services.chat_crypto import extract_message_kid +from app.services.invoice_crypto import extract_requisites_kid + + +def _xor_bytes(a: bytes, b: bytes) -> bytes: + return bytes(x ^ y for x, y in zip(a, b)) + + +def _legacy_invoice_token(secret: str) -> str: + raw = b'{"secret":"LEGACY"}' + key = hashlib.sha256(secret.encode("utf-8")).digest() + nonce = bytes.fromhex("00112233445566778899aabbccddeeff") + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw)) + cipher = _xor_bytes(raw, stream) + tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest() + token = b"v1" + nonce + tag + cipher + return base64.urlsafe_b64encode(token).decode("ascii") + + +def _legacy_chat_token(plaintext: str, secret: str) -> str: + raw = plaintext.encode("utf-8") + key = hashlib.sha256(secret.encode("utf-8")).digest() + nonce = bytes.fromhex("ffeeddccbbaa99887766554433221100") + stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(raw)) + cipher = _xor_bytes(raw, stream) + tag = hmac.new(key, b"v1" + nonce + cipher, hashlib.sha256).digest() + token = b"v1" + nonce + tag + cipher + return "chatenc:v1:" + base64.urlsafe_b64encode(token).decode("ascii") + + +class ReencryptWithKidTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) + AdminUser.__table__.create(bind=cls.engine) + Request.__table__.create(bind=cls.engine) + Message.__table__.create(bind=cls.engine) + Invoice.__table__.create(bind=cls.engine) + + cls._old_session_local = reencrypt_script.SessionLocal + reencrypt_script.SessionLocal = cls.SessionLocal + + @classmethod + def tearDownClass(cls): + reencrypt_script.SessionLocal = cls._old_session_local + Invoice.__table__.drop(bind=cls.engine) + Message.__table__.drop(bind=cls.engine) + Request.__table__.drop(bind=cls.engine) + AdminUser.__table__.drop(bind=cls.engine) + cls.engine.dispose() + + def setUp(self): + self._backup = { + "DATA_ENCRYPTION_SECRET": settings.DATA_ENCRYPTION_SECRET, + "DATA_ENCRYPTION_ACTIVE_KID": settings.DATA_ENCRYPTION_ACTIVE_KID, + "DATA_ENCRYPTION_KEYS": settings.DATA_ENCRYPTION_KEYS, + "CHAT_ENCRYPTION_SECRET": settings.CHAT_ENCRYPTION_SECRET, + "CHAT_ENCRYPTION_ACTIVE_KID": settings.CHAT_ENCRYPTION_ACTIVE_KID, + "CHAT_ENCRYPTION_KEYS": settings.CHAT_ENCRYPTION_KEYS, + } + with self.SessionLocal() as db: + db.execute(delete(Invoice)) + db.execute(delete(Message)) + db.execute(delete(Request)) + db.execute(delete(AdminUser)) + db.commit() + + def tearDown(self): + for key, value in self._backup.items(): + setattr(settings, key, value) + + def test_reencrypt_script_moves_legacy_rows_to_active_kid(self): + old_secret = "legacy-secret-aaaaaaaaaaaaaaaa" + settings.DATA_ENCRYPTION_SECRET = "" + settings.DATA_ENCRYPTION_ACTIVE_KID = "k2" + settings.DATA_ENCRYPTION_KEYS = f"k1={old_secret},k2=new-data-secret-bbbbbbbbbbbbbbbb" + settings.CHAT_ENCRYPTION_SECRET = "" + settings.CHAT_ENCRYPTION_ACTIVE_KID = "k2" + settings.CHAT_ENCRYPTION_KEYS = f"k1={old_secret},k2=new-chat-secret-cccccccccccccccc" + + with self.SessionLocal() as db: + req = Request( + track_number=f"TRK-RER-{uuid4().hex[:8].upper()}", + client_name="Клиент", + client_phone="+79990001122", + topic_code="consulting", + status_code="NEW", + extra_fields={}, + ) + db.add(req) + db.flush() + + db.add( + Message( + request_id=req.id, + author_type="CLIENT", + author_name="Клиент", + body="placeholder", + ) + ) + db.flush() + db.execute( + text("UPDATE messages SET body = :body WHERE id = (SELECT id FROM messages ORDER BY created_at DESC LIMIT 1)"), + {"body": _legacy_chat_token("legacy body", old_secret)}, + ) + + admin = AdminUser( + role="ADMIN", + name="Admin", + email=f"admin-{uuid4().hex[:6]}@example.com", + password_hash="hash", + totp_enabled=True, + totp_secret_encrypted=_legacy_invoice_token(old_secret), + is_active=True, + ) + db.add(admin) + + invoice = Invoice( + request_id=req.id, + client_id=None, + invoice_number=f"INV-{uuid4().hex[:8].upper()}", + status="WAITING_PAYMENT", + amount=1000, + currency="RUB", + payer_display_name="Клиент", + payer_details_encrypted=_legacy_invoice_token(old_secret), + issued_by_admin_user_id=None, + issued_by_role="ADMIN", + issued_at=datetime.now(timezone.utc), + responsible="seed", + ) + db.add(invoice) + db.commit() + + dry = reencrypt_script.reencrypt_with_active_kid(dry_run=True) + self.assertGreaterEqual(int(dry.get("messages_reencrypted", 0)), 1) + self.assertGreaterEqual(int(dry.get("invoices_reencrypted", 0)), 1) + self.assertGreaterEqual(int(dry.get("admin_totp_reencrypted", 0)), 1) + + applied = reencrypt_script.reencrypt_with_active_kid(dry_run=False) + self.assertGreaterEqual(int(applied.get("messages_reencrypted", 0)), 1) + self.assertGreaterEqual(int(applied.get("invoices_reencrypted", 0)), 1) + self.assertGreaterEqual(int(applied.get("admin_totp_reencrypted", 0)), 1) + self.assertEqual(int(applied.get("errors", 0)), 0) + + with self.SessionLocal() as db: + invoice_token = db.execute(text("SELECT payer_details_encrypted FROM invoices LIMIT 1")).scalar_one() + admin_token = db.execute(text("SELECT totp_secret_encrypted FROM admin_users LIMIT 1")).scalar_one() + message_token = db.execute(text("SELECT body FROM messages LIMIT 1")).scalar_one() + + self.assertEqual(extract_requisites_kid(str(invoice_token)), "k2") + self.assertEqual(extract_requisites_kid(str(admin_token)), "k2") + self.assertEqual(extract_message_kid(str(message_token)), "k2") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_s3_tls.py b/tests/test_s3_tls.py new file mode 100644 index 0000000..617c23b --- /dev/null +++ b/tests/test_s3_tls.py @@ -0,0 +1,65 @@ +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.core.config import settings +from app.services.s3_storage import S3Storage + + +class S3TlsConfigTests(unittest.TestCase): + def setUp(self): + self._backup = { + "S3_ENDPOINT": settings.S3_ENDPOINT, + "S3_ACCESS_KEY": settings.S3_ACCESS_KEY, + "S3_SECRET_KEY": settings.S3_SECRET_KEY, + "S3_BUCKET": settings.S3_BUCKET, + "S3_REGION": settings.S3_REGION, + "S3_USE_SSL": settings.S3_USE_SSL, + "S3_VERIFY_SSL": settings.S3_VERIFY_SSL, + "S3_CA_CERT_PATH": settings.S3_CA_CERT_PATH, + } + + def tearDown(self): + for key, value in self._backup.items(): + setattr(settings, key, value) + + def test_s3_client_uses_ca_bundle_for_verify(self): + settings.S3_ENDPOINT = "https://minio:9000" + settings.S3_ACCESS_KEY = "k" + settings.S3_SECRET_KEY = "s" + settings.S3_BUCKET = "b" + settings.S3_REGION = "us-east-1" + settings.S3_USE_SSL = True + settings.S3_VERIFY_SSL = True + settings.S3_CA_CERT_PATH = "/etc/ssl/minio/ca.crt" + + with patch("app.services.s3_storage.boto3.client") as boto_client: + S3Storage() + + kwargs = dict(boto_client.call_args.kwargs) + self.assertTrue(kwargs.get("use_ssl")) + self.assertEqual(kwargs.get("verify"), "/etc/ssl/minio/ca.crt") + + def test_s3_client_can_disable_verify_in_non_prod(self): + settings.S3_ENDPOINT = "https://minio:9000" + settings.S3_ACCESS_KEY = "k" + settings.S3_SECRET_KEY = "s" + settings.S3_BUCKET = "b" + settings.S3_REGION = "us-east-1" + settings.S3_USE_SSL = True + settings.S3_VERIFY_SSL = False + settings.S3_CA_CERT_PATH = "" + + with patch("app.services.s3_storage.boto3.client") as boto_client: + S3Storage() + + kwargs = dict(boto_client.call_args.kwargs) + self.assertTrue(kwargs.get("use_ssl")) + self.assertFalse(kwargs.get("verify")) diff --git a/tests/test_security_audit.py b/tests/test_security_audit.py index 9a9f24b..97d8c7a 100644 --- a/tests/test_security_audit.py +++ b/tests/test_security_audit.py @@ -159,6 +159,43 @@ class SecurityAuditTests(unittest.TestCase): self.assertEqual(str(row.attachment_id), attachment_id) self.assertEqual(row.scope, "REQUEST_ATTACHMENT") + def test_public_request_card_read_writes_pii_access_event(self): + with self.SessionLocal() as db: + req = Request( + track_number="TRK-SEC-READ-1", + client_name="Клиент", + client_phone="+79990001011", + topic_code="civil-law", + status_code="NEW", + extra_fields={}, + ) + db.add(req) + db.commit() + + public_token = create_jwt( + {"sub": "TRK-SEC-READ-1", "purpose": "VIEW_REQUEST"}, + settings.PUBLIC_JWT_SECRET, + timedelta(days=1), + ) + cookies = {settings.PUBLIC_COOKIE_NAME: public_token} + response = self.client.get("/api/public/requests/TRK-SEC-READ-1", cookies=cookies) + self.assertEqual(response.status_code, 200) + + with self.SessionLocal() as db: + rows = ( + db.query(SecurityAuditLog) + .filter( + SecurityAuditLog.action == "READ_REQUEST_CARD", + SecurityAuditLog.scope == "REQUEST_CARD", + SecurityAuditLog.actor_role == "CLIENT", + ) + .all() + ) + self.assertGreaterEqual(len(rows), 1) + row = rows[-1] + self.assertTrue(row.allowed) + self.assertEqual(row.actor_subject, "TRK-SEC-READ-1") + def test_admin_object_proxy_denied_writes_security_deny_event(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: diff --git a/tests/test_security_config.py b/tests/test_security_config.py new file mode 100644 index 0000000..6db6692 --- /dev/null +++ b/tests/test_security_config.py @@ -0,0 +1,124 @@ +import os +import unittest + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.core.config import settings, validate_production_security_or_raise + + +class SecurityConfigTests(unittest.TestCase): + def setUp(self): + self._backup = { + "APP_ENV": settings.APP_ENV, + "PRODUCTION_ENFORCE_SECURE_SETTINGS": settings.PRODUCTION_ENFORCE_SECURE_SETTINGS, + "OTP_DEV_MODE": settings.OTP_DEV_MODE, + "ADMIN_BOOTSTRAP_ENABLED": settings.ADMIN_BOOTSTRAP_ENABLED, + "PUBLIC_COOKIE_SECURE": settings.PUBLIC_COOKIE_SECURE, + "PUBLIC_COOKIE_SAMESITE": settings.PUBLIC_COOKIE_SAMESITE, + "ADMIN_JWT_SECRET": settings.ADMIN_JWT_SECRET, + "PUBLIC_JWT_SECRET": settings.PUBLIC_JWT_SECRET, + "DATA_ENCRYPTION_SECRET": settings.DATA_ENCRYPTION_SECRET, + "DATA_ENCRYPTION_ACTIVE_KID": settings.DATA_ENCRYPTION_ACTIVE_KID, + "DATA_ENCRYPTION_KEYS": settings.DATA_ENCRYPTION_KEYS, + "CHAT_ENCRYPTION_SECRET": settings.CHAT_ENCRYPTION_SECRET, + "CHAT_ENCRYPTION_ACTIVE_KID": settings.CHAT_ENCRYPTION_ACTIVE_KID, + "CHAT_ENCRYPTION_KEYS": settings.CHAT_ENCRYPTION_KEYS, + "INTERNAL_SERVICE_TOKEN": settings.INTERNAL_SERVICE_TOKEN, + "MINIO_ROOT_USER": settings.MINIO_ROOT_USER, + "MINIO_ROOT_PASSWORD": settings.MINIO_ROOT_PASSWORD, + "MINIO_TLS_ENABLED": settings.MINIO_TLS_ENABLED, + "S3_USE_SSL": settings.S3_USE_SSL, + "S3_VERIFY_SSL": settings.S3_VERIFY_SSL, + "S3_ENDPOINT": settings.S3_ENDPOINT, + "S3_CA_CERT_PATH": settings.S3_CA_CERT_PATH, + "PUBLIC_STRICT_ORIGIN_CHECK": settings.PUBLIC_STRICT_ORIGIN_CHECK, + "PUBLIC_ALLOWED_WEB_ORIGINS": settings.PUBLIC_ALLOWED_WEB_ORIGINS, + "CORS_ORIGINS": settings.CORS_ORIGINS, + "CORS_ALLOW_METHODS": settings.CORS_ALLOW_METHODS, + "CORS_ALLOW_HEADERS": settings.CORS_ALLOW_HEADERS, + } + + def tearDown(self): + for key, value in self._backup.items(): + setattr(settings, key, value) + + def test_validate_production_security_detects_insecure_values(self): + settings.APP_ENV = "prod" + settings.PRODUCTION_ENFORCE_SECURE_SETTINGS = True + settings.OTP_DEV_MODE = True + settings.ADMIN_BOOTSTRAP_ENABLED = True + settings.PUBLIC_COOKIE_SECURE = False + settings.ADMIN_JWT_SECRET = "change_me_admin" + settings.PUBLIC_JWT_SECRET = "change_me_public" + settings.DATA_ENCRYPTION_SECRET = "change_me_data_encryption" + settings.DATA_ENCRYPTION_ACTIVE_KID = "" + settings.DATA_ENCRYPTION_KEYS = "" + settings.CHAT_ENCRYPTION_SECRET = "" + settings.CHAT_ENCRYPTION_ACTIVE_KID = "" + settings.CHAT_ENCRYPTION_KEYS = "" + settings.INTERNAL_SERVICE_TOKEN = "change_me_internal_service_token" + settings.MINIO_ROOT_USER = "minioadmin" + settings.MINIO_ROOT_PASSWORD = "minioadmin" + settings.MINIO_TLS_ENABLED = False + settings.S3_USE_SSL = False + settings.S3_VERIFY_SSL = False + settings.S3_ENDPOINT = "http://minio:9000" + settings.S3_CA_CERT_PATH = "" + settings.PUBLIC_STRICT_ORIGIN_CHECK = True + settings.PUBLIC_ALLOWED_WEB_ORIGINS = "http://localhost:8080,https://ruakb.ru" + settings.CORS_ORIGINS = "*,http://localhost:8081" + settings.CORS_ALLOW_METHODS = "GET,POST,*" + settings.CORS_ALLOW_HEADERS = "Authorization,*" + + with self.assertRaises(RuntimeError) as exc: + validate_production_security_or_raise("test") + detail = str(exc.exception) + self.assertIn("OTP_DEV_MODE", detail) + self.assertIn("ADMIN_BOOTSTRAP_ENABLED", detail) + self.assertIn("MINIO_ROOT_USER", detail) + self.assertIn("MINIO_TLS_ENABLED", detail) + self.assertIn("S3_USE_SSL", detail) + self.assertIn("S3_VERIFY_SSL", detail) + self.assertIn("S3_ENDPOINT", detail) + self.assertIn("S3_CA_CERT_PATH", detail) + self.assertIn("DATA_ENCRYPTION_ACTIVE_KID", detail) + self.assertIn("PUBLIC_ALLOWED_WEB_ORIGINS", detail) + self.assertIn("CORS_ORIGINS", detail) + self.assertIn("CORS_ALLOW_METHODS", detail) + self.assertIn("CORS_ALLOW_HEADERS", detail) + + def test_validate_production_security_passes_for_hardened_values(self): + settings.APP_ENV = "production" + settings.PRODUCTION_ENFORCE_SECURE_SETTINGS = True + settings.OTP_DEV_MODE = False + settings.ADMIN_BOOTSTRAP_ENABLED = False + settings.PUBLIC_COOKIE_SECURE = True + settings.PUBLIC_COOKIE_SAMESITE = "lax" + settings.ADMIN_JWT_SECRET = "AdminJwtSecret_2026_Strong_Long" + settings.PUBLIC_JWT_SECRET = "PublicJwtSecret_2026_Strong_Long" + settings.DATA_ENCRYPTION_SECRET = "DataEncryptionSecret_2026_Strong_Long" + settings.DATA_ENCRYPTION_ACTIVE_KID = "k202603" + settings.DATA_ENCRYPTION_KEYS = "k202603=DataEncryptionSecret_2026_Strong_Long" + settings.CHAT_ENCRYPTION_SECRET = "ChatEncryptionSecret_2026_Strong_Long" + settings.CHAT_ENCRYPTION_ACTIVE_KID = "k202603" + settings.CHAT_ENCRYPTION_KEYS = "k202603=ChatEncryptionSecret_2026_Strong_Long" + settings.INTERNAL_SERVICE_TOKEN = "InternalServiceToken_2026_Strong_Long" + settings.MINIO_ROOT_USER = "law_prod_minio_user" + settings.MINIO_ROOT_PASSWORD = "LawProdMinioSecretKey_2026_Strong" + settings.MINIO_TLS_ENABLED = True + settings.S3_USE_SSL = True + settings.S3_VERIFY_SSL = True + settings.S3_ENDPOINT = "https://minio:9000" + settings.S3_CA_CERT_PATH = "/etc/ssl/minio/ca.crt" + settings.PUBLIC_STRICT_ORIGIN_CHECK = True + settings.PUBLIC_ALLOWED_WEB_ORIGINS = "https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online" + settings.CORS_ORIGINS = "https://ruakb.ru,https://www.ruakb.ru,https://ruakb.online,https://www.ruakb.online" + settings.CORS_ALLOW_METHODS = "GET,POST,PUT,PATCH,DELETE,OPTIONS" + settings.CORS_ALLOW_HEADERS = "Authorization,Content-Type,X-Requested-With,X-Request-ID" + + validate_production_security_or_raise("test") diff --git a/tests/test_worker_maintenance.py b/tests/test_worker_maintenance.py index 552fc0b..f483afc 100644 --- a/tests/test_worker_maintenance.py +++ b/tests/test_worker_maintenance.py @@ -15,10 +15,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test") os.environ.setdefault("S3_BUCKET", "test") from app.models.attachment import Attachment +from app.models.audit_log import AuditLog +from app.models.data_retention_policy import DataRetentionPolicy from app.models.message import Message from app.models.notification import Notification from app.models.otp_session import OtpSession from app.models.request import Request +from app.models.security_audit_log import SecurityAuditLog from app.models.status import Status from app.models.status_history import StatusHistory from app.models.topic_status_transition import TopicStatusTransition @@ -37,6 +40,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase): ) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) OtpSession.__table__.create(bind=cls.engine) + DataRetentionPolicy.__table__.create(bind=cls.engine) + AuditLog.__table__.create(bind=cls.engine) + SecurityAuditLog.__table__.create(bind=cls.engine) Request.__table__.create(bind=cls.engine) Attachment.__table__.create(bind=cls.engine) Status.__table__.create(bind=cls.engine) @@ -64,6 +70,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase): Status.__table__.drop(bind=cls.engine) Attachment.__table__.drop(bind=cls.engine) Request.__table__.drop(bind=cls.engine) + SecurityAuditLog.__table__.drop(bind=cls.engine) + AuditLog.__table__.drop(bind=cls.engine) + DataRetentionPolicy.__table__.drop(bind=cls.engine) OtpSession.__table__.drop(bind=cls.engine) cls.engine.dispose() @@ -76,6 +85,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase): db.execute(delete(Notification)) db.execute(delete(Attachment)) db.execute(delete(Request)) + db.execute(delete(SecurityAuditLog)) + db.execute(delete(AuditLog)) + db.execute(delete(DataRetentionPolicy)) db.execute(delete(OtpSession)) db.commit() @@ -113,6 +125,167 @@ class WorkerMaintenanceTaskTests(unittest.TestCase): self.assertEqual(len(remaining), 1) self.assertEqual(remaining[0].track_number, "TRK-ACT") + def test_cleanup_pii_retention_deletes_old_rows_by_policy(self): + now = datetime.now(timezone.utc) + old = now - timedelta(days=10) + with self.SessionLocal() as db: + db.add_all( + [ + DataRetentionPolicy( + entity="otp_sessions", + retention_days=1, + enabled=True, + hard_delete=True, + description="OTP", + responsible="test", + ), + DataRetentionPolicy( + entity="notifications", + retention_days=1, + enabled=True, + hard_delete=True, + description="Notifications", + responsible="test", + ), + DataRetentionPolicy( + entity="audit_log", + retention_days=1, + enabled=True, + hard_delete=True, + description="Audit", + responsible="test", + ), + DataRetentionPolicy( + entity="security_audit_log", + retention_days=1, + enabled=True, + hard_delete=True, + description="Security audit", + responsible="test", + ), + DataRetentionPolicy( + entity="requests", + retention_days=3650, + enabled=False, + hard_delete=True, + description="Requests disabled", + responsible="test", + ), + ] + ) + req = Request( + track_number="TRK-RET-1", + client_name="Клиент", + client_phone="+79990003001", + topic_code="civil", + status_code="NEW", + extra_fields={}, + created_at=old, + updated_at=old, + ) + db.add(req) + db.flush() + db.add_all( + [ + OtpSession( + purpose="VIEW_REQUEST", + track_number="TRK-RET-OTP-OLD", + phone="+79990000011", + code_hash="old", + attempts=0, + expires_at=now + timedelta(minutes=5), + created_at=old, + updated_at=old, + ), + OtpSession( + purpose="VIEW_REQUEST", + track_number="TRK-RET-OTP-NEW", + phone="+79990000012", + code_hash="new", + attempts=0, + expires_at=now + timedelta(minutes=5), + created_at=now, + updated_at=now, + ), + Notification( + request_id=req.id, + recipient_type="CLIENT", + recipient_track_number=req.track_number, + event_type="STATUS_CHANGED", + title="old", + body="old", + payload={}, + created_at=old, + updated_at=old, + ), + Notification( + request_id=req.id, + recipient_type="CLIENT", + recipient_track_number=req.track_number, + event_type="STATUS_CHANGED", + title="new", + body="new", + payload={}, + created_at=now, + updated_at=now, + ), + AuditLog( + entity="requests", + entity_id=str(req.id), + action="READ", + diff={}, + created_at=old, + updated_at=old, + ), + AuditLog( + entity="requests", + entity_id=str(req.id), + action="READ", + diff={}, + created_at=now, + updated_at=now, + ), + SecurityAuditLog( + actor_role="CLIENT", + actor_subject=req.track_number, + actor_ip="127.0.0.1", + action="READ_REQUEST_CARD", + scope="REQUEST", + request_id=req.id, + allowed=True, + details={}, + created_at=old, + updated_at=old, + ), + SecurityAuditLog( + actor_role="CLIENT", + actor_subject=req.track_number, + actor_ip="127.0.0.1", + action="READ_REQUEST_CARD", + scope="REQUEST", + request_id=req.id, + allowed=True, + details={}, + created_at=now, + updated_at=now, + ), + ] + ) + db.commit() + + result = security_task.cleanup_pii_retention() + deleted = dict(result.get("deleted") or {}) + self.assertGreaterEqual(int(deleted.get("otp_sessions", 0)), 1) + self.assertGreaterEqual(int(deleted.get("notifications", 0)), 1) + self.assertGreaterEqual(int(deleted.get("audit_log", 0)), 1) + self.assertGreaterEqual(int(deleted.get("security_audit_log", 0)), 1) + + with self.SessionLocal() as db: + self.assertEqual(db.query(OtpSession).count(), 1) + self.assertEqual(db.query(Notification).count(), 1) + self.assertEqual(db.query(AuditLog).count(), 1) + self.assertEqual(db.query(SecurityAuditLog).count(), 1) + def test_cleanup_stale_uploads_removes_invalid_and_fixes_totals(self): with self.SessionLocal() as db: req1 = Request(