add security test

This commit is contained in:
TronoSfera 2026-03-02 16:22:07 +03:00
parent a06f553406
commit 85ac21a1cb
75 changed files with 5072 additions and 151 deletions

135
.env.production Normal file
View file

@ -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

183
.github/workflows/security-ci.yml vendored Normal file
View file

@ -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

5
.gitignore vendored
View file

@ -1,6 +1,8 @@
/tmp/ /tmp/
*.idea *.idea
.env .env
.env.prod
.env.backup.*
/reports /reports
node_modules/ node_modules/
e2e/node_modules/ e2e/node_modules/
@ -8,4 +10,5 @@ e2e/playwright-report/
e2e/test-results/ e2e/test-results/
celerybeat-schedule celerybeat-schedule
celerybeat-schedule.* celerybeat-schedule.*
deploy/tls/minio/*
!deploy/tls/minio/.gitkeep

View file

@ -2,6 +2,9 @@
help \ help \
local-up local-down local-logs local-migrate local-test local-seed \ local-up local-down local-logs local-migrate local-test local-seed \
prod-up prod-down prod-logs prod-ps prod-migrate \ 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 \ prod-cert-init prod-cert-renew \
check-prod-files check-cert-files \ check-prod-files check-cert-files \
run migrate test seed-quotes run migrate test seed-quotes
@ -11,6 +14,8 @@ WWW_DOMAIN ?= www.ruakb.ru
SECOND_DOMAIN ?= ruakb.online SECOND_DOMAIN ?= ruakb.online
SECOND_WWW_DOMAIN ?= www.ruakb.online SECOND_WWW_DOMAIN ?= www.ruakb.online
LETSENCRYPT_EMAIL ?= admin@ruakb.ru 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)") 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 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-logs - Tail production logs"
@echo " prod-ps - Show production services" @echo " prod-ps - Show production services"
@echo " prod-migrate - Apply migrations (prod)" @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-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)"
@echo " prod-cert-renew - Renew existing certificates" @echo " prod-cert-renew - Renew existing certificates"
@echo "" @echo ""
@ -38,6 +51,7 @@ help:
@echo " WWW_DOMAIN=$(WWW_DOMAIN)" @echo " WWW_DOMAIN=$(WWW_DOMAIN)"
@echo " SECOND_DOMAIN=$(SECOND_DOMAIN)" @echo " SECOND_DOMAIN=$(SECOND_DOMAIN)"
@echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)" @echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)"
@echo " AUTO_CERT_INIT=$(AUTO_CERT_INIT)"
local-up: local-up:
$(LOCAL_COMPOSE) up -d --build $(LOCAL_COMPOSE) up -d --build
@ -59,6 +73,8 @@ local-seed:
check-prod-files: 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 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 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) @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-migrate: check-prod-files
$(PROD_COMPOSE) exec -T backend alembic upgrade head $(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: # Initial certificate bootstrap:
# 1) Start stack with edge nginx on port 80 only. # 1) Start stack with edge nginx on port 80 only.
# 2) Obtain cert via certbot webroot challenge. # 2) Obtain cert via certbot webroot challenge.

168
README.md
View file

@ -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 (ruakb.ru + ruakb.online, 80/443, TLS via Nginx + Certbot)
Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`). Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`).
Use production template before first deploy:
```bash
cp .env.production .env
```
Prerequisites: Prerequisites:
- DNS `A` record: `ruakb.ru -> 45.150.36.116` - DNS `A` record: `ruakb.ru -> 45.150.36.116`
- Optional DNS `A` record: `www.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:<password>@db:5432/legal` - `DATABASE_URL=postgresql+psycopg://postgres:<password>@db:5432/legal`
- `POSTGRES_PASSWORD=<same password>` - `POSTGRES_PASSWORD=<same 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=<strong-random-secret>
DATA_ENCRYPTION_SECRET=<strong-random-secret>
DATA_ENCRYPTION_ACTIVE_KID=<kid>
DATA_ENCRYPTION_KEYS=<kid>=<strong-random-secret>
CHAT_ENCRYPTION_ACTIVE_KID=<kid>
CHAT_ENCRYPTION_KEYS=<kid>=<strong-random-secret>
ADMIN_JWT_SECRET=<strong-random-secret>
PUBLIC_JWT_SECRET=<strong-random-secret>
INTERNAL_SERVICE_TOKEN=<strong-random-secret>
MINIO_ROOT_USER=<non-default-user>
MINIO_ROOT_PASSWORD=<strong-random-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): Initial certificate issue (bootstrap with nginx on port 80 only):
```bash ```bash
make prod-cert-init LETSENCRYPT_EMAIL=you@example.com DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru 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
``` ```
`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: Certificate renew:
```bash ```bash
make prod-cert-renew 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: Checks:
```bash ```bash
curl -I https://ruakb.ru curl -I https://ruakb.ru
curl -fsS https://ruakb.ru/health curl -fsS https://ruakb.ru/health
curl -fsS https://ruakb.ru/chat-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 - <<PY\nfrom app.services.s3_storage import get_s3_storage\nprint(get_s3_storage().client._endpoint.host)\nPY'
ss -lntp | egrep ':(80|443|5432|6379|8002|8081|9000|9001)\\b' ss -lntp | egrep ':(80|443|5432|6379|8002|8081|9000|9001)\\b'
``` ```
## Incident response (PDn)
Create a standard incident checklist report:
```bash
make incident-checklist
```
or with details:
```bash
./scripts/ops/incident_checklist.sh \
--severity HIGH \
--category UNAUTHORIZED_ACCESS \
--summary "Suspicious read access to request cards" \
--track-number TRK-XXXX
```
Generated markdown report is saved to `reports/incidents/incident-<timestamp>.md`.
## Migrations ## Migrations
```bash ```bash
docker compose exec backend alembic upgrade head docker compose exec backend alembic upgrade head
@ -179,6 +274,10 @@ CLAMAV_PORT=3310
CLAMAV_TIMEOUT_SECONDS=20 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`: Scan statuses on `attachments`:
- `PENDING` (file uploaded, scan in progress) - `PENDING` (file uploaded, scan in progress)
- `CLEAN` (safe to download) - `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. 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-<timestamp>.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 ## Container health and alerting
Docker Compose is configured with: Docker Compose is configured with:
- `restart: unless-stopped` for core services - `restart: unless-stopped` for core services

View file

@ -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")

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID 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 sqlalchemy.orm import Session
from app.core.deps import require_role from app.core.deps import require_role
@ -26,11 +26,35 @@ from app.services.chat_secure_service import (
serialize_messages_for_request, serialize_messages_for_request,
) )
from app.services.chat_presence import list_typing_presence, set_typing_presence 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() router = APIRouter()
ALLOWED_VALUE_TYPES = {"string", "text", "date", "number", "file"} 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: def _parse_cursor(raw: str | None) -> datetime | None:
value = str(raw or "").strip() value = str(raw or "").strip()
if not value: if not value:
@ -223,13 +247,23 @@ def _serialize_data_request_items(db: Session, rows: list[RequestDataRequirement
@router.get("/requests/{request_id}/messages") @router.get("/requests/{request_id}/messages")
def list_request_messages( def list_request_messages(
request_id: str, request_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
): ):
req = _request_for_id_or_404(db, request_id) req = _request_for_id_or_404(db, request_id)
_ensure_lawyer_can_view_request_or_403(admin, req) _ensure_lawyer_can_view_request_or_403(admin, req)
rows = list_messages_for_request(db, req.id) 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) @router.post("/requests/{request_id}/messages", status_code=201)
@ -268,6 +302,7 @@ def create_request_message(
@router.get("/requests/{request_id}/live") @router.get("/requests/{request_id}/live")
def get_request_live_state( def get_request_live_state(
request_id: str, request_id: str,
http_request: FastapiRequest,
cursor: str | None = None, cursor: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), 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_role = str(admin.get("role") or "").strip().upper() or "UNKNOWN"
actor_key = f"{actor_role}:{actor_sub}" actor_key = f"{actor_role}:{actor_sub}"
typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key) typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
return { payload = {
"request_id": str(req.id), "request_id": str(req.id),
"cursor": latest_activity_iso, "cursor": latest_activity_iso,
"has_updates": has_updates, "has_updates": has_updates,
@ -299,6 +334,15 @@ def get_request_live_state(
request_id=req.id, 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") @router.post("/requests/{request_id}/typing")
@ -374,6 +418,7 @@ def list_data_request_templates(
def get_data_request_batch( def get_data_request_batch(
request_id: str, request_id: str,
message_id: str, message_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")), admin: dict = Depends(require_role("ADMIN", "LAWYER", "CURATOR")),
): ):
@ -394,13 +439,22 @@ def get_data_request_batch(
) )
if not rows: if not rows:
raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден") raise HTTPException(status_code=404, detail="Набор данных для сообщения не найден")
return { payload = {
"message_id": str(message.id), "message_id": str(message.id),
"request_id": str(req.id), "request_id": str(req.id),
"track_number": req.track_number, "track_number": req.track_number,
"document_name": rows[0].document_name if rows else None, "document_name": rows[0].document_name if rows else None,
"items": _serialize_data_request_items(db, rows), "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}") @router.get("/requests/{request_id}/data-request-templates/{template_id}")

View file

@ -52,6 +52,7 @@ TABLE_ROLE_ACTIONS: dict[str, dict[str, set[str]]] = {
"form_fields": {"ADMIN": set(CRUD_ACTIONS)}, "form_fields": {"ADMIN": set(CRUD_ACTIONS)},
"clients": {"ADMIN": set(CRUD_ACTIONS)}, "clients": {"ADMIN": set(CRUD_ACTIONS)},
"table_availability": {"ADMIN": set(CRUD_ACTIONS)}, "table_availability": {"ADMIN": set(CRUD_ACTIONS)},
"data_retention_policies": {"ADMIN": set(CRUD_ACTIONS)},
"audit_log": {"ADMIN": {"query", "read"}}, "audit_log": {"ADMIN": {"query", "read"}},
"security_audit_log": {"ADMIN": {"query", "read"}}, "security_audit_log": {"ADMIN": {"query", "read"}},
"otp_sessions": {"ADMIN": {"query", "read"}}, "otp_sessions": {"ADMIN": {"query", "read"}},

View file

@ -84,6 +84,7 @@ def _table_label(table_name: str) -> str:
"form_fields": "Поля формы", "form_fields": "Поля формы",
"clients": "Клиенты", "clients": "Клиенты",
"table_availability": "Доступность таблиц", "table_availability": "Доступность таблиц",
"data_retention_policies": "Политики хранения ПДн",
"topic_required_fields": "Обязательные поля темы", "topic_required_fields": "Обязательные поля темы",
"topic_data_templates": "Дополнительные данные", "topic_data_templates": "Дополнительные данные",
"request_data_templates": "Шаблоны доп. данных", "request_data_templates": "Шаблоны доп. данных",
@ -101,6 +102,9 @@ def _table_label(table_name: str) -> str:
"request_service_requests": "Запросы", "request_service_requests": "Запросы",
"otp_sessions": "OTP-сессии", "otp_sessions": "OTP-сессии",
"notifications": "Уведомления", "notifications": "Уведомления",
"retention": "хранения",
"policy": "политика",
"policies": "политики",
} }
if normalized in explicit_labels: if normalized in explicit_labels:
return explicit_labels[normalized] return explicit_labels[normalized]
@ -250,6 +254,11 @@ def _column_label(table_name: str, column_name: str) -> str:
"required_data_keys": "Обязательные данные шага", "required_data_keys": "Обязательные данные шага",
"required_mime_types": "Обязательные файлы шага", "required_mime_types": "Обязательные файлы шага",
"avatar_url": "Аватар", "avatar_url": "Аватар",
"pdn_consent": "Согласие на ПДн",
"pdn_consent_at": "Дата согласия ПДн",
"pdn_consent_ip": "IP согласия",
"retention_days": "Срок хранения (дней)",
"hard_delete": "Жесткое удаление",
"file_name": "Имя файла", "file_name": "Имя файла",
"mime_type": "MIME-тип", "mime_type": "MIME-тип",
"size_bytes": "Размер (байт)", "size_bytes": "Размер (байт)",

View file

@ -5,7 +5,7 @@ from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from uuid import UUID, uuid4 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 fastapi.responses import StreamingResponse
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -18,6 +18,7 @@ from app.models.request import Request
from app.schemas.universal import UniversalQuery from app.schemas.universal import UniversalQuery
from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes 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 from app.services.universal_query import apply_universal_query
router = APIRouter() router = APIRouter()
@ -193,6 +194,7 @@ def _commit_or_400(db: Session, detail: str) -> None:
@router.post("/query") @router.post("/query")
def query_invoices( def query_invoices(
uq: UniversalQuery, uq: UniversalQuery,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")), admin: dict = Depends(require_role("ADMIN", "LAWYER")),
): ):
@ -223,12 +225,25 @@ def query_invoices(
) )
for row in rows 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}") @router.get("/{invoice_id}")
def get_invoice( def get_invoice(
invoice_id: str, invoice_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")), admin: dict = Depends(require_role("ADMIN", "LAWYER")),
): ):
@ -245,12 +260,25 @@ def get_invoice(
_ensure_lawyer_owns_request_or_403(role, actor_id, req) _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 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, invoice,
request_track=req.track_number, request_track=req.track_number,
issuer_name=issuer.name if issuer else None, issuer_name=issuer.name if issuer else None,
include_payer_details=True, 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) @router.post("", status_code=201)
@ -406,6 +434,7 @@ def delete_invoice(
@router.get("/{invoice_id}/pdf") @router.get("/{invoice_id}/pdf")
def download_invoice_pdf( def download_invoice_pdf(
invoice_id: str, invoice_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: dict = Depends(require_role("ADMIN", "LAWYER")), admin: dict = Depends(require_role("ADMIN", "LAWYER")),
): ):
@ -435,6 +464,18 @@ def download_invoice_pdf(
issued_by_name=(issuer.name if issuer else None), issued_by_name=(issuer.name if issuer else None),
requisites=requisites, 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" file_name = f"{invoice.invoice_number}.pdf"
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}

View file

@ -1,6 +1,6 @@
from __future__ import annotations 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 sqlalchemy.orm import Session
from app.core.deps import require_role from app.core.deps import require_role
@ -15,6 +15,7 @@ from app.schemas.admin import (
RequestStatusChange, RequestStatusChange,
) )
from app.schemas.universal import UniversalQuery from app.schemas.universal import UniversalQuery
from app.services.security_audit import extract_client_ip, record_pii_access_event
from .data_templates import ( from .data_templates import (
create_request_data_requirement_service, 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}") @router.get("/{request_id}")
def get_request(request_id: str, db: Session = Depends(get_db), admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR"))): def get_request(
return get_request_service(request_id, db, admin) 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") @router.post("/{request_id}/status-change")
@ -97,10 +116,24 @@ def change_request_status(
@router.get("/{request_id}/status-route") @router.get("/{request_id}/status-route")
def get_request_status_route( def get_request_status_route(
request_id: str, request_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin=Depends(require_role("ADMIN", "LAWYER", "CURATOR")), 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") @router.post("/{request_id}/claim")

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from uuid import UUID 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 sqlalchemy.orm import Session
from app.core.deps import get_public_session from app.core.deps import get_public_session
@ -22,6 +22,8 @@ from app.services.chat_secure_service import (
serialize_messages_for_request, serialize_messages_for_request,
) )
from app.services.request_read_markers import EVENT_REQUEST_DATA, mark_unread_for_lawyer 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() router = APIRouter()
@ -102,6 +104,38 @@ def _require_view_session_or_403(session: dict) -> str:
return subject 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: 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() req = db.query(Request).filter(Request.track_number == _normalize_track(track_number)).first()
if req is None: 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") @router.get("/requests/{track_number}/messages")
def list_messages_by_track( def list_messages_by_track(
track_number: str, track_number: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
req = _request_for_track_or_404(db, track_number) req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req) _ensure_view_access_or_403(session, req)
rows = list_messages_for_request(db, req.id) 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) @router.post("/requests/{track_number}/messages", status_code=201)
def create_message_by_track( def create_message_by_track(
track_number: str, track_number: str,
payload: PublicMessageCreate, payload: PublicMessageCreate,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req) _ensure_view_access_or_403(session, req)
row = create_client_message(db, request=req, body=payload.body) 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") @router.get("/requests/{track_number}/live")
def get_live_chat_state_by_track( def get_live_chat_state_by_track(
track_number: str, track_number: str,
http_request: FastapiRequest,
cursor: str | None = None, cursor: str | None = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
@ -164,7 +211,7 @@ def get_live_chat_state_by_track(
subject = _require_view_session_or_403(session) subject = _require_view_session_or_403(session)
actor_key = f"CLIENT:{_normalize_track(subject) or _normalize_phone(subject)}" 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) typing_rows = list_typing_presence(request_key=str(req.id), exclude_actor_key=actor_key)
return { payload = {
"track_number": req.track_number, "track_number": req.track_number,
"cursor": latest_activity_iso, "cursor": latest_activity_iso,
"has_updates": has_updates, "has_updates": has_updates,
@ -179,15 +226,26 @@ def get_live_chat_state_by_track(
request_id=req.id, 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") @router.post("/requests/{track_number}/typing")
def set_live_chat_typing_by_track( def set_live_chat_typing_by_track(
track_number: str, track_number: str,
payload: dict, payload: dict,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req) _ensure_view_access_or_403(session, req)
subject = _require_view_session_or_403(session) subject = _require_view_session_or_403(session)
@ -207,6 +265,7 @@ def set_live_chat_typing_by_track(
def get_data_request_by_message( def get_data_request_by_message(
track_number: str, track_number: str,
message_id: str, message_id: str,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
@ -230,7 +289,7 @@ def get_data_request_by_message(
) )
if not rows: if not rows:
raise HTTPException(status_code=404, detail="Запрос данных не найден") raise HTTPException(status_code=404, detail="Запрос данных не найден")
return { payload = {
"message_id": str(message.id), "message_id": str(message.id),
"request_id": str(req.id), "request_id": str(req.id),
"track_number": req.track_number, "track_number": req.track_number,
@ -248,6 +307,15 @@ def get_data_request_by_message(
for row in rows 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}") @router.post("/requests/{track_number}/data-requests/{message_id}")
@ -255,9 +323,14 @@ def save_data_request_values(
track_number: str, track_number: str,
message_id: str, message_id: str,
payload: dict, payload: dict,
http_request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, track_number)
_ensure_view_access_or_403(session, req) _ensure_view_access_or_403(session, req)
try: try:

View file

@ -14,6 +14,7 @@ from app.models.otp_session import OtpSession
from app.models.request import Request as RequestModel from app.models.request import Request as RequestModel
from app.schemas.public import OtpSend, OtpVerify from app.schemas.public import OtpSend, OtpVerify
from app.services.email_service import EmailDeliveryError, send_otp_email_message 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.rate_limit import get_rate_limiter
from app.services.sms_service import SmsDeliveryError, send_otp_message, sms_provider_health 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 "" return ""
def _is_honeypot_tripped(raw: str | None) -> bool:
return bool(str(raw or "").strip())
def _auth_mode() -> str: def _auth_mode() -> str:
mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower() mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower()
if mode not in SUPPORTED_AUTH_MODES: 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, key=settings.PUBLIC_COOKIE_NAME,
value=token, value=token,
httponly=True, httponly=True,
secure=False, secure=settings.public_cookie_secure_effective,
samesite="lax", samesite=settings.public_cookie_samesite_effective,
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600, max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600,
) )
@ -211,6 +216,9 @@ def get_auth_config():
@router.post("/send") @router.post("/send")
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): 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) purpose = _normalize_purpose(payload.purpose)
if purpose not in ALLOWED_PURPOSES: if purpose not in ALLOWED_PURPOSES:
raise HTTPException(status_code=400, detail="Некорректная цель OTP") 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") @router.post("/verify")
def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Session = Depends(get_db)): 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) purpose = _normalize_purpose(payload.purpose)
if purpose not in ALLOWED_PURPOSES: if purpose not in ALLOWED_PURPOSES:
raise HTTPException(status_code=400, detail="Некорректная цель OTP") raise HTTPException(status_code=400, detail="Некорректная цель OTP")

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
from uuid import uuid4 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 fastapi.responses import StreamingResponse
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session 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_crypto import decrypt_requisites
from app.services.invoice_pdf import build_invoice_pdf_bytes 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.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 ( from app.services.notifications import (
get_client_notification, get_client_notification,
list_client_notifications, 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_read_markers import clear_unread_for_client
from app.services.request_templates import validate_required_topic_fields_or_400 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.api.admin.requests_modules.status_flow import get_request_status_route_service
from app.schemas.public import ( from app.schemas.public import (
PublicAttachmentRead, PublicAttachmentRead,
@ -78,6 +80,14 @@ def _normalize_track(raw: str | None) -> str:
return str(raw or "").strip().upper() 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: def _set_view_cookie(response: Response, subject: str) -> None:
token = create_jwt( token = create_jwt(
{"sub": subject, "purpose": OTP_VIEW_PURPOSE}, {"sub": subject, "purpose": OTP_VIEW_PURPOSE},
@ -88,12 +98,48 @@ def _set_view_cookie(response: Response, subject: str) -> None:
key=settings.PUBLIC_COOKIE_NAME, key=settings.PUBLIC_COOKIE_NAME,
value=token, value=token,
httponly=True, httponly=True,
secure=False, secure=settings.public_cookie_secure_effective,
samesite="lax", samesite=settings.public_cookie_samesite_effective,
max_age=settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600, 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: 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() purpose = str(session.get("purpose") or "").strip().upper()
subject = str(session.get("sub") or "").strip() subject = str(session.get("sub") or "").strip()
@ -211,9 +257,15 @@ def _public_invoice_payload(row: Invoice, track_number: str) -> dict:
def create_request( def create_request(
payload: PublicRequestCreate, payload: PublicRequestCreate,
response: Response, response: Response,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) _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) validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields)
client = _upsert_client_by_phone( client = _upsert_client_by_phone(
@ -233,9 +285,28 @@ def create_request(
topic_code=payload.topic_code, topic_code=payload.topic_code,
description=payload.description, description=payload.description,
extra_fields=payload.extra_fields, extra_fields=payload.extra_fields,
pdn_consent=True,
pdn_consent_at=_now_utc(),
pdn_consent_ip=extract_client_ip(request),
responsible="Клиент", responsible="Клиент",
) )
db.add(row) 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.commit()
db.refresh(row) db.refresh(row)
@ -256,6 +327,7 @@ def list_public_topics(db: Session = Depends(get_db)):
@router.get("/my") @router.get("/my")
def list_my_requests( def list_my_requests(
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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 by_event[event_key] = int(by_event.get(event_key, 0)) + event_count
bucket["by_event"] = by_event bucket["by_event"] = by_event
bucket["total"] = int(bucket.get("total", 0)) + event_count bucket["total"] = int(bucket.get("total", 0)) + event_count
return { payload = {
"rows": [ "rows": [
{ {
"id": str(row.id), "id": str(row.id),
@ -319,11 +391,21 @@ def list_my_requests(
], ],
"total": len(rows), "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}") @router.get("/{track_number}")
def get_request_by_track( def get_request_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
@ -370,7 +452,7 @@ def get_request_by_track(
request_id=req.id, request_id=req.id,
) )
return { payload = {
"id": str(req.id), "id": str(req.id),
"client_id": str(req.client_id) if req.client_id else None, "client_id": str(req.client_id) if req.client_id else None,
"track_number": req.track_number, "track_number": req.track_number,
@ -397,11 +479,22 @@ def get_request_by_track(
"created_at": _to_iso(req.created_at), "created_at": _to_iso(req.created_at),
"updated_at": _to_iso(req.updated_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") @router.get("/{track_number}/status-route")
def get_status_route_by_track( def get_status_route_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
@ -413,11 +506,20 @@ def get_status_route_by_track(
{"role": "ADMIN", "sub": "", "email": "Клиент"}, {"role": "ADMIN", "sub": "", "email": "Клиент"},
) )
payload["available_statuses"] = [] 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 return payload
except Exception: except Exception:
current = str(req.status_code or "").strip() current = str(req.status_code or "").strip()
changed_at = _to_iso(req.updated_at or req.created_at) changed_at = _to_iso(req.updated_at or req.created_at)
return { payload = {
"request_id": str(req.id), "request_id": str(req.id),
"track_number": req.track_number, "track_number": req.track_number,
"topic_code": req.topic_code, "topic_code": req.topic_code,
@ -450,17 +552,28 @@ def get_status_route_by_track(
if current if current
else [], 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]) @router.get("/{track_number}/messages", response_model=list[PublicMessageRead])
def list_messages_by_track( def list_messages_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
req = _request_for_track_or_404(db, session, track_number) req = _request_for_track_or_404(db, session, track_number)
rows = list_messages_for_request(db, req.id) rows = list_messages_for_request(db, req.id)
return [ payload = [
PublicMessageRead( PublicMessageRead(
id=row.id, id=row.id,
request_id=row.request_id, request_id=row.request_id,
@ -472,15 +585,27 @@ def list_messages_by_track(
) )
for row in rows 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) @router.post("/{track_number}/messages", response_model=PublicMessageRead, status_code=201)
def create_message_by_track( def create_message_by_track(
track_number: str, track_number: str,
payload: PublicMessageCreate, payload: PublicMessageCreate,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, session, track_number)
row = create_client_message(db, request=req, body=payload.body) 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]) @router.get("/{track_number}/attachments", response_model=list[PublicAttachmentRead])
def list_attachments_by_track( def list_attachments_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
@ -508,7 +634,7 @@ def list_attachments_by_track(
.order_by(Attachment.created_at.desc(), Attachment.id.desc()) .order_by(Attachment.created_at.desc(), Attachment.id.desc())
.all() .all()
) )
return [ payload = [
PublicAttachmentRead( PublicAttachmentRead(
id=row.id, id=row.id,
request_id=row.request_id, request_id=row.request_id,
@ -521,11 +647,22 @@ def list_attachments_by_track(
) )
for row in rows 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") @router.get("/{track_number}/invoices")
def list_invoices_by_track( def list_invoices_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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()) .order_by(Invoice.issued_at.desc(), Invoice.created_at.desc(), Invoice.id.desc())
.all() .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") @router.get("/{track_number}/invoices/{invoice_id}/pdf")
def download_invoice_pdf_by_track( def download_invoice_pdf_by_track(
track_number: str, track_number: str,
invoice_id: str, invoice_id: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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), issued_by_name=(issuer.name if issuer else invoice.issued_by_role),
requisites=requisites, 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" file_name = f"{invoice.invoice_number}.pdf"
headers = {"Content-Disposition": f'attachment; filename="{file_name}"'} headers = {"Content-Disposition": f'attachment; filename="{file_name}"'}
return StreamingResponse(iter([pdf_bytes]), media_type="application/pdf", headers=headers) 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]) @router.get("/{track_number}/history", response_model=list[PublicStatusHistoryRead])
def list_status_history_by_track( def list_status_history_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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()) .order_by(StatusHistory.created_at.asc(), StatusHistory.id.asc())
.all() .all()
) )
return [ payload = [
PublicStatusHistoryRead( PublicStatusHistoryRead(
id=row.id, id=row.id,
request_id=row.request_id, request_id=row.request_id,
@ -599,11 +757,22 @@ def list_status_history_by_track(
) )
for row in rows 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]) @router.get("/{track_number}/timeline", response_model=list[PublicTimelineEvent])
def list_timeline_by_track( def list_timeline_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), session: dict = Depends(get_public_session),
): ):
@ -658,6 +827,15 @@ def list_timeline_by_track(
return event.created_at or "" return event.created_at or ""
events.sort(key=_sort_key) 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 return events
@ -665,9 +843,11 @@ def list_timeline_by_track(
def create_service_request_by_track( def create_service_request_by_track(
track_number: str, track_number: str,
payload: PublicServiceRequestCreate, payload: PublicServiceRequestCreate,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, session, track_number)
request_type = str(payload.type or "").strip().upper() request_type = str(payload.type or "").strip().upper()
if request_type not in SERVICE_REQUEST_TYPES: 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]) @router.get("/{track_number}/service-requests", response_model=list[PublicServiceRequestRead])
def list_service_requests_by_track( def list_service_requests_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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()) .order_by(RequestServiceRequest.created_at.desc(), RequestServiceRequest.id.desc())
.all() .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") @router.get("/{track_number}/notifications")
def list_notifications_by_track( def list_notifications_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
unread_only: bool = False, unread_only: bool = False,
limit: int = 50, limit: int = 50,
offset: int = 0, offset: int = 0,
@ -762,20 +954,32 @@ def list_notifications_by_track(
limit=1, limit=1,
offset=0, offset=0,
) )
return { payload = {
"rows": [serialize_notification(row) for row in rows], "rows": [serialize_notification(row) for row in rows],
"total": int(total), "total": int(total),
"unread_total": int(unread_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") @router.post("/{track_number}/notifications/{notification_id}/read")
def read_notification_by_track( def read_notification_by_track(
track_number: str, track_number: str,
notification_id: str, notification_id: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, session, track_number)
try: try:
notification_uuid = UUID(str(notification_id)) notification_uuid = UUID(str(notification_id))
@ -799,9 +1003,11 @@ def read_notification_by_track(
@router.post("/{track_number}/notifications/read-all") @router.post("/{track_number}/notifications/read-all")
def read_all_notifications_by_track( def read_all_notifications_by_track(
track_number: str, track_number: str,
request: FastapiRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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) req = _request_for_track_or_404(db, session, track_number)
changed = mark_client_notifications_read( changed = mark_client_notifications_read(
db, db,

View file

@ -25,6 +25,7 @@ from app.services.attachment_scan import (
initial_scan_status_for_new_attachment, initial_scan_status_for_new_attachment,
) )
from app.services.s3_storage import build_object_key, get_s3_storage 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() router = APIRouter()
@ -105,6 +106,7 @@ def upload_init(
db: Session = Depends(get_db), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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_subject = str(session.get("sub") or "").strip()
actor_ip = _client_ip(http_request) actor_ip = _client_ip(http_request)
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) 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), db: Session = Depends(get_db),
session: dict = Depends(get_public_session), 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_subject = str(session.get("sub") or "").strip()
actor_ip = _client_ip(http_request) actor_ip = _client_ip(http_request)
scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope) scope_name = str(payload.scope.value if hasattr(payload.scope, "value") else payload.scope)

View file

@ -4,16 +4,16 @@ from fastapi.responses import JSONResponse
from app.api.admin.chat import router as admin_chat_router from app.api.admin.chat import router as admin_chat_router
from app.api.public.chat import router as public_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 from app.core.http_hardening import install_http_hardening
app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0") app = FastAPI(title=f"{settings.APP_NAME}-chat", version="0.1.0")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=["*"], allow_methods=settings.cors_allow_methods_list,
allow_headers=["*"], allow_headers=settings.cors_allow_headers_list,
) )
install_http_hardening(app) 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.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) @app.get("/", include_in_schema=False)
def landing(): def landing():
return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"}) return JSONResponse({"service": f"{settings.APP_NAME}-chat", "status": "ok"})

View file

@ -18,8 +18,20 @@ class Settings(BaseSettings):
TOTP_ISSUER: str = "Правовой Трекер" TOTP_ISSUER: str = "Правовой Трекер"
PUBLIC_JWT_SECRET: str = "change_me_public" PUBLIC_JWT_SECRET: str = "change_me_public"
PUBLIC_COOKIE_NAME: str = "public_jwt" 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_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 DATABASE_URL: str
REDIS_URL: str REDIS_URL: str
@ -30,6 +42,8 @@ class Settings(BaseSettings):
S3_BUCKET: str S3_BUCKET: str
S3_REGION: str = "us-east-1" S3_REGION: str = "us-east-1"
S3_USE_SSL: bool = False S3_USE_SSL: bool = False
S3_VERIFY_SSL: bool = True
S3_CA_CERT_PATH: str = ""
MAX_FILE_MB: int = 25 MAX_FILE_MB: int = 25
MAX_CASE_MB: int = 250 MAX_CASE_MB: int = 250
ATTACHMENT_SCAN_ENABLED: bool = False ATTACHMENT_SCAN_ENABLED: bool = False
@ -64,6 +78,10 @@ class Settings(BaseSettings):
OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}" OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}"
OTP_EMAIL_FALLBACK_ENABLED: bool = True OTP_EMAIL_FALLBACK_ENABLED: bool = True
OTP_SMS_MIN_BALANCE: float = 20.0 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" DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
CHAT_ENCRYPTION_SECRET: str = "" CHAT_ENCRYPTION_SECRET: str = ""
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
@ -79,9 +97,154 @@ class Settings(BaseSettings):
POSTGRES_USER: str = "postgres" POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres" POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str = "legal" 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 @property
def cors_origins_list(self) -> List[str]: def cors_origins_list(self) -> List[str]:
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()] 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() 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}")

View file

@ -20,13 +20,35 @@ SECURITY_HEADERS = {
"Cross-Origin-Embedder-Policy": "credentialless", "Cross-Origin-Embedder-Policy": "credentialless",
"Cross-Origin-Resource-Policy": "same-origin", "Cross-Origin-Resource-Policy": "same-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()", "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 = { FRAMEABLE_FILE_SECURITY_HEADERS = {
**SECURITY_HEADERS, **SECURITY_HEADERS,
"X-Frame-Options": "SAMEORIGIN", "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 = ( _FRAMEABLE_PATH_PATTERNS = (

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import FastAPI, Header, HTTPException from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel 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 from app.services.email_service import EmailDeliveryError, send_email_via_smtp
app = FastAPI(title="law-email-service") app = FastAPI(title="law-email-service")
@ -15,6 +15,11 @@ class InternalEmailSend(BaseModel):
body: str body: str
@app.on_event("startup")
def _validate_security_config_on_startup() -> None:
validate_production_security_or_raise("email-service")
@app.get("/health") @app.get("/health")
def health(): def health():
return {"status": "ok", "service": "email-service"} return {"status": "ok", "service": "email-service"}

View file

@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse 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.core.http_hardening import install_http_hardening
from app.api.public.router import router as public_router from app.api.public.router import router as public_router
from app.api.admin.router import router as admin_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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=settings.CORS_ALLOW_CREDENTIALS,
allow_methods=["*"], allow_methods=settings.cors_allow_methods_list,
allow_headers=["*"], allow_headers=settings.cors_allow_headers_list,
) )
install_http_hardening(app) install_http_hardening(app)
app.include_router(public_router, prefix="/api/public") app.include_router(public_router, prefix="/api/public")
app.include_router(admin_router, prefix="/api/admin") 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) @app.get("/", include_in_schema=False)
def landing(): def landing():
return JSONResponse({"service": settings.APP_NAME, "status": "ok"}) return JSONResponse({"service": settings.APP_NAME, "status": "ok"})

View file

@ -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)

View file

@ -14,6 +14,9 @@ class Request(Base, UUIDMixin, TimestampMixin):
client_name: Mapped[str] = mapped_column(String(200), nullable=False) client_name: Mapped[str] = mapped_column(String(200), nullable=False)
client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) 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) 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) 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") 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) important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)

View file

@ -6,6 +6,8 @@ class PublicRequestCreate(BaseModel):
client_name: str client_name: str
client_phone: str client_phone: str
client_email: Optional[str] = None client_email: Optional[str] = None
pdn_consent: bool = False
hp_field: Optional[str] = None
topic_code: Optional[str] = None topic_code: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
extra_fields: Dict[str, Any] = Field(default_factory=dict) extra_fields: Dict[str, Any] = Field(default_factory=dict)
@ -21,6 +23,7 @@ class OtpSend(BaseModel):
track_number: Optional[str] = None track_number: Optional[str] = None
client_phone: Optional[str] = None client_phone: Optional[str] = None
client_email: Optional[str] = None client_email: Optional[str] = None
hp_field: Optional[str] = None
channel: Optional[str] = None channel: Optional[str] = None
class OtpVerify(BaseModel): class OtpVerify(BaseModel):

View file

@ -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()

View file

@ -5,39 +5,42 @@ import hashlib
import hmac import hmac
import secrets 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" _VERSION_LEGACY = b"v1"
_PREFIX = "chatenc:v1:" _PREFIX_LEGACY = "chatenc:v1:"
_PREFIX_V2 = "chatenc:v2:"
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()
def _xor_bytes(a: bytes, b: bytes) -> bytes: def _xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b)) 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: def is_encrypted_message(value: str | None) -> bool:
token = str(value or "").strip() 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: def encrypt_message_body(value: str | None) -> str | None:
@ -48,13 +51,57 @@ def encrypt_message_body(value: str | None) -> str | None:
return text return text
if is_encrypted_message(text): if is_encrypted_message(text):
return 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") raw = text.encode("utf-8")
nonce = secrets.token_bytes(16) 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) cipher = _xor_bytes(raw, stream)
tag = hmac.new(_key(), _VERSION + nonce + cipher, hashlib.sha256).digest() tag = hmac.new(key, _aad_v2(active_kid) + nonce + cipher, hashlib.sha256).digest()
token = _VERSION + nonce + tag + cipher blob = nonce + tag + cipher
return _PREFIX + base64.urlsafe_b64encode(token).decode("ascii") 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: def decrypt_message_body(value: str | None) -> str | None:
@ -65,19 +112,23 @@ def decrypt_message_body(value: str | None) -> str | None:
return text return text
if not is_encrypted_message(text): if not is_encrypted_message(text):
return text return text
encoded = text[len(_PREFIX) :]
blob = base64.urlsafe_b64decode(encoded.encode("ascii")) active_kid, key_map = get_chat_secrets()
if len(blob) < 2 + 16 + 32: _ = active_kid
raise ValueError("Некорректный зашифрованный формат сообщения") if text.startswith(_PREFIX_V2):
version = blob[:2] encoded = text[len(_PREFIX_V2) :]
nonce = blob[2:18] parts = encoded.split(":", 1)
tag = blob[18:50] if len(parts) != 2:
cipher = blob[50:] raise ValueError("Некорректный зашифрованный формат сообщения")
if version != _VERSION: kid, payload = str(parts[0] or "").strip(), parts[1]
raise ValueError("Неподдерживаемая версия шифрования чата") if kid in key_map:
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest() return _decrypt_v2(payload, kid=kid, key=key_digest(key_map[kid]))
if not hmac.compare_digest(tag, expected): for fallback_key in ordered_unique_key_digests(key_map.values()):
raise ValueError("Поврежденные данные сообщения") try:
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher)) return _decrypt_v2(payload, kid=kid, key=fallback_key)
raw = _xor_bytes(cipher, stream) except Exception:
return raw.decode("utf-8") continue
raise ValueError("Неподдерживаемый идентификатор ключа шифрования")
encoded = text[len(_PREFIX_LEGACY) :]
return _decrypt_legacy(encoded, ordered_unique_key_digests(key_map.values()))

View file

@ -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

View file

@ -7,54 +7,120 @@ import json
import secrets import secrets
from typing import Any 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" _VERSION_LEGACY = b"v1"
_PREFIX_V2 = "invenc:v2:"
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()
def _xor_bytes(a: bytes, b: bytes) -> bytes: def _xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b)) return bytes(x ^ y for x, y in zip(a, b))
def encrypt_requisites(data: dict[str, Any] | None) -> str: def _aad_v2(kid: str) -> bytes:
payload = dict(data or {}) return b"v2|" + str(kid).encode("utf-8") + b"|"
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 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() encoded = str(token or "").strip()
if not encoded: 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")) 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: if len(blob) < 2 + 16 + 32:
raise ValueError("Некорректные зашифрованные реквизиты") raise ValueError("Некорректные зашифрованные реквизиты")
version = blob[:2] version = blob[:2]
nonce = blob[2:18] nonce = blob[2:18]
tag = blob[18:50] tag = blob[18:50]
cipher = blob[50:] cipher = blob[50:]
if version != _VERSION: if version != _VERSION_LEGACY:
raise ValueError("Неподдерживаемая версия шифрования") raise ValueError("Неподдерживаемая версия шифрования")
expected = hmac.new(_key(), version + nonce + cipher, hashlib.sha256).digest()
if not hmac.compare_digest(tag, expected): for key in keys:
raise ValueError("Поврежденные зашифрованные реквизиты") expected = hmac.new(key, version + nonce + cipher, hashlib.sha256).digest()
stream = hashlib.pbkdf2_hmac("sha256", _key(), nonce, 120_000, dklen=len(cipher)) if not hmac.compare_digest(tag, expected):
raw = _xor_bytes(cipher, stream) continue
data = json.loads(raw.decode("utf-8")) stream = hashlib.pbkdf2_hmac("sha256", key, nonce, 120_000, dklen=len(cipher))
return data if isinstance(data, dict) else {} 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()))

View file

@ -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}")

View file

@ -24,6 +24,10 @@ def build_object_key(prefix: str, file_name: str) -> str:
class S3Storage: class S3Storage:
def __init__(self): def __init__(self):
self.bucket = settings.S3_BUCKET 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( self.client = boto3.client(
"s3", "s3",
endpoint_url=settings.S3_ENDPOINT, endpoint_url=settings.S3_ENDPOINT,
@ -31,6 +35,7 @@ class S3Storage:
aws_secret_access_key=settings.S3_SECRET_KEY, aws_secret_access_key=settings.S3_SECRET_KEY,
region_name=settings.S3_REGION, region_name=settings.S3_REGION,
use_ssl=settings.S3_USE_SSL, use_ssl=settings.S3_USE_SSL,
verify=verify_ssl,
) )
self._bucket_checked = False self._bucket_checked = False

View file

@ -5,6 +5,7 @@ import uuid
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from fastapi import Request as FastapiRequest
from sqlalchemy import func, inspect from sqlalchemy import func, inspect
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -41,6 +42,19 @@ def _safe_details(details: dict[str, Any] | None) -> dict[str, Any]:
return safe 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( def _emit_suspicious_denied_download_alert(
db: Session, db: Session,
*, *,
@ -127,3 +141,33 @@ def record_file_security_event(
db.rollback() db.rollback()
except Exception: except Exception:
logger.debug("security_audit_rollback_failed", exc_info=True) 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,
)

View file

@ -8,8 +8,8 @@
</head> </head>
<body> <body>
<div id="admin-root"></div> <div id="admin-root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script> <script src="/vendor/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script> <script src="/vendor/react-dom.production.min.js"></script>
<script src="/admin.js?v=20260225-12"></script> <script src="/admin.js?v=20260225-12"></script>
</body> </body>
</html> </html>

View file

@ -9,8 +9,8 @@
</head> </head>
<body> <body>
<div id="client-root"></div> <div id="client-root"></div>
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script> <script src="/vendor/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script> <script src="/vendor/react-dom.production.min.js"></script>
<script src="/client.js?v=20260227-02"></script> <script src="/client.js?v=20260227-02"></script>
</body> </body>
</html> </html>

View file

@ -515,6 +515,16 @@
.field.full { grid-column: 1 / -1; } .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 { label {
font-size: 0.76rem; font-size: 0.76rem;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@ -523,6 +533,36 @@
font-weight: 700; 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 { input, textarea, select {
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;

View file

@ -183,6 +183,10 @@
<button class="close" type="button" data-close-modal aria-label="Закрыть">×</button> <button class="close" type="button" data-close-modal aria-label="Закрыть">×</button>
</div> </div>
<form id="request-form" class="form"> <form id="request-form" class="form">
<div class="hp-field-wrap" aria-hidden="true">
<label for="request-hp-field">Сайт компании</label>
<input id="request-hp-field" name="request-company-website" type="text" autocomplete="off" tabindex="-1">
</div>
<div class="field"> <div class="field">
<label for="name">Имя</label> <label for="name">Имя</label>
<input id="name" name="name" type="text" required placeholder="Иван Иванов"> <input id="name" name="name" type="text" required placeholder="Иван Иванов">
@ -205,6 +209,12 @@
<label for="description">Описание задачи</label> <label for="description">Описание задачи</label>
<textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea> <textarea id="description" name="description" placeholder="Кратко опишите ситуацию"></textarea>
</div> </div>
<div class="field full consent-field">
<label class="consent-check">
<input id="pdn-consent" name="pdn-consent" type="checkbox" required>
<span>Согласен(а) на обработку персональных данных согласно <a href="/privacy.html" target="_blank" rel="noopener">политике ПДн</a>.</span>
</label>
</div>
<div class="form-foot field full"> <div class="form-foot field full">
<button class="btn btn-primary" type="submit">Отправить заявку</button> <button class="btn btn-primary" type="submit">Отправить заявку</button>
<p class="status" id="form-status"></p> <p class="status" id="form-status"></p>
@ -223,6 +233,10 @@
<button class="close" type="button" data-close-access aria-label="Закрыть">×</button> <button class="close" type="button" data-close-access aria-label="Закрыть">×</button>
</div> </div>
<form id="access-form" class="form"> <form id="access-form" class="form">
<div class="hp-field-wrap" aria-hidden="true">
<label for="access-hp-field">Сайт компании</label>
<input id="access-hp-field" name="access-company-website" type="text" autocomplete="off" tabindex="-1">
</div>
<div class="field"> <div class="field">
<label for="access-phone">Телефон</label> <label for="access-phone">Телефон</label>
<input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00"> <input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00">

View file

@ -15,6 +15,7 @@
const accessForm = document.getElementById("access-form"); const accessForm = document.getElementById("access-form");
const accessPhoneInput = document.getElementById("access-phone"); const accessPhoneInput = document.getElementById("access-phone");
const accessEmailInput = document.getElementById("access-email"); const accessEmailInput = document.getElementById("access-email");
const accessHpInput = document.getElementById("access-hp-field");
const accessCodeInput = document.getElementById("access-code"); const accessCodeInput = document.getElementById("access-code");
const accessSendOtpButton = document.getElementById("access-send-otp"); const accessSendOtpButton = document.getElementById("access-send-otp");
const accessStatus = document.getElementById("access-status"); const accessStatus = document.getElementById("access-status");
@ -33,6 +34,7 @@
const featuredTeamPrev = document.getElementById("featured-team-prev"); const featuredTeamPrev = document.getElementById("featured-team-prev");
const featuredTeamNext = document.getElementById("featured-team-next"); const featuredTeamNext = document.getElementById("featured-team-next");
const requestEmailInput = document.getElementById("email"); const requestEmailInput = document.getElementById("email");
const requestHpInput = document.getElementById("request-hp-field");
let otpModalResolver = null; let otpModalResolver = null;
let lastAccessOtpChannel = "SMS"; let lastAccessOtpChannel = "SMS";
let lastCreateOtpChannel = "SMS"; let lastCreateOtpChannel = "SMS";
@ -408,6 +410,7 @@
accessSendOtpButton.addEventListener("click", async () => { accessSendOtpButton.addEventListener("click", async () => {
const phone = String(accessPhoneInput.value || "").trim(); const phone = String(accessPhoneInput.value || "").trim();
const email = normalizeEmail(accessEmailInput?.value); const email = normalizeEmail(accessEmailInput?.value);
const hpField = String(accessHpInput?.value || "").trim();
const channel = preferredChannel({ phone, email }); const channel = preferredChannel({ phone, email });
if (currentAuthMode() === "totp") { if (currentAuthMode() === "totp") {
setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error"); setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error");
@ -431,6 +434,7 @@
purpose: "VIEW_REQUEST", purpose: "VIEW_REQUEST",
client_phone: phone, client_phone: phone,
client_email: email, client_email: email,
hp_field: hpField,
channel, channel,
}), }),
}); });
@ -493,6 +497,8 @@
client_name: String(document.getElementById("name").value || "").trim(), client_name: String(document.getElementById("name").value || "").trim(),
client_phone: String(document.getElementById("phone").value || "").trim(), client_phone: String(document.getElementById("phone").value || "").trim(),
client_email: normalizeEmail(requestEmailInput?.value), 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(), topic_code: String(document.getElementById("topic").value || "").trim(),
description: String(document.getElementById("description").value || "").trim(), description: String(document.getElementById("description").value || "").trim(),
extra_fields: {}, extra_fields: {},
@ -511,6 +517,10 @@
setStatus(requestStatus, "Заполните имя и тему обращения.", "error"); setStatus(requestStatus, "Заполните имя и тему обращения.", "error");
return; return;
} }
if (!payload.pdn_consent) {
setStatus(requestStatus, "Необходимо согласие на обработку персональных данных.", "error");
return;
}
try { try {
setStatus(requestStatus, "Отправляем OTP-код...", null); setStatus(requestStatus, "Отправляем OTP-код...", null);
@ -521,6 +531,7 @@
purpose: "CREATE_REQUEST", purpose: "CREATE_REQUEST",
client_phone: payload.client_phone, client_phone: payload.client_phone,
client_email: payload.client_email, client_email: payload.client_email,
hp_field: payload.hp_field,
channel: createChannel, channel: createChannel,
}), }),
}); });

56
app/web/privacy.html Normal file
View file

@ -0,0 +1,56 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Политика обработки персональных данных</title>
<style>
body {
margin: 0;
font-family: "Manrope", sans-serif;
background: #0d1217;
color: #e7eef8;
line-height: 1.6;
}
.wrap {
max-width: 900px;
margin: 0 auto;
padding: 32px 20px 56px;
}
h1, h2 { margin-top: 0; }
h1 { font-size: 2rem; margin-bottom: 1rem; }
h2 { font-size: 1.2rem; margin-top: 1.8rem; }
a { color: #f4d7a8; }
.note {
border: 1px solid rgba(207, 217, 231, 0.22);
border-radius: 12px;
padding: 12px 14px;
background: rgba(255,255,255,0.03);
color: #b8c8dd;
font-size: 0.92rem;
}
</style>
</head>
<body>
<main class="wrap">
<h1>Политика обработки персональных данных</h1>
<p>Настоящий документ определяет порядок обработки персональных данных пользователей платформы «Правовой трекер».</p>
<div class="note">Заполните реквизиты оператора, юридический адрес, контакты DPO/ответственного и иные обязательные сведения перед публикацией.</div>
<h2>1. Какие данные обрабатываются</h2>
<p>ФИО, номер телефона, адрес электронной почты, содержание обращений, сообщения и файлы, загружаемые в рамках заявки.</p>
<h2>2. Цели обработки</h2>
<p>Регистрация и сопровождение заявок, юридическая консультация, связь с пользователем, исполнение договорных и законных обязанностей оператора.</p>
<h2>3. Сроки хранения</h2>
<p>Сроки хранения и удаления данных определяются внутренними политиками хранения ПДн и требованиями законодательства РФ.</p>
<h2>4. Права субъекта ПДн</h2>
<p>Пользователь имеет право запрашивать уточнение, блокирование или удаление своих персональных данных в случаях, предусмотренных законом.</p>
<h2>5. Контакты</h2>
<p>По вопросам обработки персональных данных обращайтесь по контактам, указанным на сайте оператора.</p>
</main>
</body>
</html>

View file

@ -1,5 +1,7 @@
from celery import Celery 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 = Celery("legal_case_tracker", broker=settings.REDIS_URL, backend=settings.REDIS_URL)
celery_app.conf.imports = ( 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}, "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}, "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_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}, "cleanup_stale_uploads": {"task": "app.workers.tasks.uploads.cleanup_stale_uploads", "schedule": 86400.0},
} }
celery_app.conf.timezone = "Europe/Moscow" celery_app.conf.timezone = "Europe/Moscow"

View file

@ -1,9 +1,23 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from datetime import timedelta
from uuid import UUID
from app.db.session import SessionLocal 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.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 from app.workers.celery_app import celery_app
@ -21,3 +35,186 @@ def cleanup_expired_otps():
raise raise
finally: finally:
db.close() 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()

View file

@ -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` | | 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-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-<timestamp>.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` | | 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 | | 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` | | 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 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 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 --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 внешнего контура).

View file

@ -1,7 +1,7 @@
# План доработки конфигурации безопасности ПДн (РФ) # План доработки конфигурации безопасности ПДн (РФ)
Дата: 01.03.2026 Дата: 01.03.2026
Статус документа: `в работе` Статус документа: `сделано`
Цель: привести техническую конфигурацию платформы к актуальным базовым требованиям по защите ПДн в РФ, с приоритетом на быстрое снижение юридических и эксплуатационных рисков. Цель: привести техническую конфигурацию платформы к актуальным базовым требованиям по защите ПДн в РФ, с приоритетом на быстрое снижение юридических и эксплуатационных рисков.
## Контекст для ИИ-агента ## Контекст для ИИ-агента
@ -29,21 +29,21 @@
| ID | Приоритет | Статус | Задача | Что сделать | Артефакт / DoD | | 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-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`, пустые ключи шифрования. | Фейл старта с понятной ошибкой; документировано в README. | | 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 через скрипт. | Скрипт `scripts/ops/create_admin.py` (или аналог), bootstrap отключен на проде. | | 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 при старте. | | 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. | Загрузка/скачивание работает по TLS; health-check и smoke зафиксированы в runbook. | | 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/` + `scripts/ops/incident_checklist.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-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-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`) и процедуру ротации без потери расшифровки. | Документ + миграция формата (если нужна) + smoke ротации ключа. | | 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 с аудитом. | Конфиг retention + job + отчёт по удалению + тесты. | | 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 | На лендинге добавить явное согласие с ссылкой на политику обработки ПДн. Логировать факт согласия. | Новый публичный документ политики + поле/аудит согласия при создании заявки. | | 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` и убрать внешние источники без необходимости. | Конфиг профилей + тесты/проверка заголовков. | | 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/` с шаблонами и заполненными draft. | | 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. | CI job + пороги fail + отчёт в артефактах. | | 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` + запись в runbook. | | 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-<timestamp>.md`, инструкция cron в `README.md`. |
## Последовательность внедрения ## Последовательность внедрения
1. `SEC-01``SEC-05` (закрытие P0 в коде/конфиге). 1. `SEC-01``SEC-05` (закрытие P0 в коде/конфиге).
@ -70,5 +70,9 @@
- Указан rollback шаг. - Указан rollback шаг.
## Статус исполнения ## Статус исполнения
- `SEC-01``SEC-15`: `к разработке`. - `SEC-01`, `SEC-02`, `SEC-03`, `SEC-04`, `SEC-07`, `SEC-08`, `SEC-10`, `SEC-11`: `сделано`.
- После выполнения переводить поштучно в `сделано` с датой и ссылкой на commit/PR. - `SEC-12`: `сделано`.
- `SEC-13`: `сделано`.
- `SEC-14`: `сделано`.
- `SEC-15`: `сделано`.
- Все пункты `SEC-01..SEC-15` закрыты.

View file

@ -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 задачи.

View file

@ -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=<kid>`
- `DATA_ENCRYPTION_KEYS=<kid1>=<secret1>,<kid2>=<secret2>`
- `CHAT_ENCRYPTION_ACTIVE_KID=<kid>`
- `CHAT_ENCRYPTION_KEYS=<kid1>=<secret1>,<kid2>=<secret2>`
Примечание: старые ключи удалять только после полной перешифровки и верификации.
## Порядок ротации
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 зелёные.

View file

@ -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 { server {
listen 80; listen 80;
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
server_tokens off; 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/ { location /.well-known/acme-challenge/ {
root /var/www/certbot; root /var/www/certbot;
try_files $uri =404; 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 / { location / {
proxy_pass http://frontend:80; proxy_pass http://frontend:80;
proxy_http_version 1.1; proxy_http_version 1.1;

View file

@ -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 { server {
listen 80; listen 80;
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
@ -18,6 +24,12 @@ server {
http2 on; http2 on;
server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online;
server_tokens off; 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 /etc/letsencrypt/live/ruakb.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/ruakb.ru/privkey.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-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" 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 / { location / {
proxy_pass http://frontend:80; proxy_pass http://frontend:80;

View file

View file

@ -20,3 +20,7 @@ services:
ports: ports:
- "9000:9000" - "9000:9000"
- "9001:9001" - "9001:9001"
# Local/dev: use multi-arch image (works on Apple Silicon/ARM64).
clamav:
image: mkodockx/docker-clamav:alpine

View file

@ -3,6 +3,13 @@ services:
image: nginx:1.27-alpine image: nginx:1.27-alpine
container_name: law-edge container_name: law-edge
restart: unless-stopped restart: unless-stopped
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
depends_on: depends_on:
frontend: frontend:
condition: service_healthy condition: service_healthy
@ -24,9 +31,23 @@ services:
frontend: frontend:
ports: [] 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: backend:
ports: [] ports: []
volumes:
- ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro
security_opt:
- no-new-privileges:true
db: db:
ports: [] ports: []
@ -36,6 +57,34 @@ services:
minio: minio:
ports: [] 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: volumes:
letsencrypt: letsencrypt:

View file

@ -3,6 +3,11 @@ services:
image: caddy:2.8.4-alpine image: caddy:2.8.4-alpine
container_name: law-edge container_name: law-edge
restart: unless-stopped restart: unless-stopped
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp
depends_on: depends_on:
frontend: frontend:
condition: service_healthy condition: service_healthy
@ -16,9 +21,23 @@ services:
frontend: frontend:
ports: [] 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: backend:
ports: [] ports: []
volumes:
- ./deploy/tls/minio/ca.crt:/etc/ssl/minio/ca.crt:ro
security_opt:
- no-new-privileges:true
db: db:
ports: [] ports: []
@ -28,6 +47,34 @@ services:
minio: minio:
ports: [] 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: volumes:
caddy_data: caddy_data:

View file

@ -155,8 +155,8 @@ services:
restart: unless-stopped restart: unless-stopped
command: server /data --console-address ":9001" command: server /data --console-address ":9001"
environment: environment:
MINIO_ROOT_USER: minioadmin MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio_local_admin}
MINIO_ROOT_PASSWORD: minioadmin MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio_local_password_change_me}
volumes: ["miniodata:/data"] volumes: ["miniodata:/data"]
clamav: clamav:

14
docs/security/README.md Normal file
View file

@ -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 проекта.

View file

@ -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` после реализации добавить отдельные строки в эту таблицу.

View file

@ -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 (регламенты, приказы, журналы).

View file

@ -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/*` — технические документы ИСПДн.

View file

@ -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-архив логов, формализация орг-контролей.

View file

@ -5,13 +5,18 @@ COPY app/web/admin.jsx ./admin.jsx
COPY app/web/client ./client COPY app/web/client ./client
COPY app/web/client.jsx ./client.jsx COPY app/web/client.jsx ./client.jsx
RUN npm init -y >/dev/null 2>&1 \ 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 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 FROM nginx:1.27-alpine
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY app/web/ /usr/share/nginx/html/ 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/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/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 RUN cp /usr/share/nginx/html/landing.html /usr/share/nginx/html/index.html

View file

@ -15,7 +15,7 @@ server {
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" 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; expires 10m;
return 302 /admin.html; return 302 /admin.html;
} }
@ -28,7 +28,7 @@ server {
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" 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; expires 10m;
return 302 /admin.html; return 302 /admin.html;
} }
@ -41,7 +41,7 @@ server {
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" 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; expires 10m;
default_type application/javascript; default_type application/javascript;
try_files $uri =404; try_files $uri =404;
@ -55,7 +55,7 @@ server {
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "credentialless" always; add_header Cross-Origin-Embedder-Policy "credentialless" always;
add_header Cross-Origin-Resource-Policy "same-origin" 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; expires 10m;
try_files $uri /index.html; try_files $uri /index.html;
} }

121
frontend/nginx.prod.conf Normal file
View file

@ -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;
}
}

View file

@ -9,6 +9,168 @@ if [[ ! -f .env ]]; then
exit 1 exit 1
fi 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..." echo "[1/4] Build and start production stack..."
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

140
scripts/ops/incident_checklist.sh Executable file
View file

@ -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 <<USAGE
Usage:
scripts/ops/incident_checklist.sh [options]
Options:
--severity <LOW|MEDIUM|HIGH|CRITICAL>
--category <PDN_LEAK|UNAUTHORIZED_ACCESS|MALWARE_UPLOAD|SERVICE_COMPROMISE|PDN_SUSPECTED>
--summary <text>
--request-id <uuid>
--track-number <trk>
--reporter <name/email>
--output <path> 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" <<REPORT
# Инцидент ПДн: первичный чек-лист
- Дата/время (UTC): ${TS_UTC}
- Хост: ${HOSTNAME_VALUE}
- Уровень: ${SEVERITY}
- Категория: ${CATEGORY}
- Инициатор: ${REPORTER:-не указан}
- Request ID: ${REQUEST_ID:-не указан}
- Track number: ${TRACK_NUMBER:-не указан}
- Краткое описание: ${SUMMARY}
## 1. Немедленные действия (0-15 минут)
- [ ] Назначен ответственный за инцидент
- [ ] Заморожены потенциально компрометированные учетные данные
- [ ] Включен усиленный сбор логов и запрет на удаление логов
- [ ] Зафиксировано текущее состояние сервисов и времени обнаружения
## 2. Техническая локализация
- [ ] Проверены подозрительные события в таблице 'security_audit_log' (READ/UPLOAD/DOWNLOAD)
- [ ] Проверены события CRUD в таблице 'audit_log'
- [ ] Проверены запросы к заявкам/чатам/счетам по IP/actor_subject
- [ ] Ограничен доступ к затронутым данным (временный deny/rotate/rbac tighten)
## 3. Коммуникации и эскалация
- [ ] Уведомлен владелец системы и ответственный по ИБ/ПДн
- [ ] Определен объем затронутых ПДн и категорий субъектов
- [ ] Принято решение о юридических уведомлениях (внутренний контур)
## 4. Восстановление
- [ ] Выполнена ротация секретов/ключей (JWT, INTERNAL_SERVICE_TOKEN, внешние API)
- [ ] Проверена целостность данных и корректность бизнес-процессов
- [ ] Выполнен пост-инцидентный тест smoke + регрессионные проверки
## 5. Артефакты и evidence
- [ ] Сохранены логи сервисов (docker compose logs --since ...)
- [ ] Сохранены выгрузки из security_audit_log и audit_log
- [ ] Зафиксированы hash-суммы ключевых файлов evidence
## 6. Текущие health-checks
- backend: ${BACKEND_HEALTH}
- chat-service: ${CHAT_HEALTH}
- email-service: ${EMAIL_HEALTH}
## 7. Команды для первичного анализа
a. Просмотр последних security-событий:
~~~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 100;"
~~~
b. Просмотр последних CRUD-аудитов:
~~~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 100;"
~~~
c. Снимок логов контейнеров:
~~~bash
docker compose logs --since 2h backend chat-service worker beat edge > reports/incidents/logs-${TS_FILE}.txt
~~~
REPORT
echo "[OK] Incident checklist created: $OUTPUT_FILE"

View file

@ -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" <<CFG
[req]
default_bits = 4096
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = req_ext
[dn]
CN = ${SERVER_CN}
[req_ext]
subjectAltName = @alt_names
[alt_names]
DNS.1 = minio
DNS.2 = law-minio
DNS.3 = localhost
IP.1 = 127.0.0.1
CFG
echo "[1/4] Generating internal CA..."
openssl genrsa -out "$OUT_DIR/ca.key" 4096 >/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"

View file

@ -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 "$@"

View file

@ -0,0 +1,157 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE=".env"
KID=""
DATA_SECRET=""
CHAT_SECRET=""
usage() {
cat <<USAGE
Usage:
scripts/ops/rotate_encryption_kid.sh [options]
Options:
--env-file <path> Env file to update (default: .env)
--kid <kid> KID to activate (default: kYYYYMMDDHHMM)
--data-secret <value> DATA key secret (default: generated)
--chat-secret <value> 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"

View file

@ -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 <file> Source env template (default: .env.production)
--env-out <file> Output env file with rotated secrets (default: .env.prod)
--compose-override <file> 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 <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"

302
scripts/ops/security_smoke.sh Executable file
View file

@ -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 - <<PY
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
value = """${not_after}""".strip()
try:
dt = parsedate_to_datetime(value)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
left = int((dt - datetime.now(timezone.utc)).total_seconds() // 86400)
print(left)
except Exception:
print(-9999)
PY
)"
if [[ "$days_left" == "-9999" ]]; then
add_fail "tls certificate date parse error for ${host}:${port}"
return 1
fi
if (( days_left < 7 )); then
add_fail "tls certificate expires too soon (${days_left} days) for ${host}:${port}"
return 1
fi
add_pass "tls certificate valid for ${host}:${port} (days_left=${days_left})"
return 0
}
check_cookie_and_security_flags() {
if [[ ! -f ".env" ]]; then
add_warn ".env not found: config smoke checks skipped"
return 0
fi
local app_env cookie_secure samesite strict_origin
app_env="$(lower "$(read_env_var APP_ENV)")"
cookie_secure="$(read_env_var PUBLIC_COOKIE_SECURE)"
samesite="$(lower "$(read_env_var PUBLIC_COOKIE_SAMESITE)")"
strict_origin="$(read_env_var PUBLIC_STRICT_ORIGIN_CHECK)"
if [[ "$app_env" == "prod" || "$app_env" == "production" ]]; then
if is_truthy "$cookie_secure"; then
add_pass "env: PUBLIC_COOKIE_SECURE=true (prod)"
else
add_fail "env: PUBLIC_COOKIE_SECURE must be true in prod"
fi
if [[ "$samesite" == "lax" || "$samesite" == "strict" || "$samesite" == "none" ]]; then
add_pass "env: PUBLIC_COOKIE_SAMESITE is valid (${samesite})"
else
add_fail "env: invalid PUBLIC_COOKIE_SAMESITE (${samesite})"
fi
if is_truthy "$strict_origin"; then
add_pass "env: PUBLIC_STRICT_ORIGIN_CHECK=true (prod)"
else
add_fail "env: PUBLIC_STRICT_ORIGIN_CHECK must be true in prod"
fi
else
add_warn "APP_ENV=${app_env:-unknown}: prod-only cookie checks skipped"
fi
}
check_compose_service_running() {
local service="$1"
if ! command -v docker >/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

View file

@ -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()

View file

@ -92,4 +92,7 @@ class HttpHardeningTests(unittest.TestCase):
} }
headers = _response_security_headers(Request(scope)) headers = _response_security_headers(Request(scope))
self.assertEqual(headers.get("X-Frame-Options"), "DENY") 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)

View file

@ -105,6 +105,7 @@ class MigrationTests(unittest.TestCase):
"notifications", "notifications",
"invoices", "invoices",
"security_audit_log", "security_audit_log",
"data_retention_policies",
"alembic_version", "alembic_version",
} }
tables = set(self.inspector.get_table_names()) tables = set(self.inspector.get_table_names())
@ -113,7 +114,7 @@ class MigrationTests(unittest.TestCase):
def test_alembic_version_is_set(self): def test_alembic_version_is_set(self):
with self.engine.connect() as conn: with self.engine.connect() as conn:
version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() 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): def test_responsible_column_exists_in_all_domain_tables(self):
tables = { tables = {
@ -143,6 +144,7 @@ class MigrationTests(unittest.TestCase):
"notifications", "notifications",
"invoices", "invoices",
"security_audit_log", "security_audit_log",
"data_retention_policies",
} }
for table in tables: for table in tables:
columns = {column["name"] for column in self.inspector.get_columns(table)} 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")} columns = {column["name"] for column in self.inspector.get_columns("requests")}
self.assertIn("client_id", columns) self.assertIn("client_id", columns)
self.assertIn("client_email", 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("important_date_at", columns)
self.assertIn("effective_rate", columns) self.assertIn("effective_rate", columns)
self.assertIn("request_cost", columns) self.assertIn("request_cost", columns)
@ -200,6 +205,17 @@ class MigrationTests(unittest.TestCase):
self.assertIn("paid_at", columns) self.assertIn("paid_at", columns)
self.assertIn("paid_by_admin_id", 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): def test_status_history_contains_important_date_column(self):
columns = {column["name"] for column in self.inspector.get_columns("status_history")} columns = {column["name"] for column in self.inspector.get_columns("status_history")}
self.assertIn("important_date_at", columns) self.assertIn("important_date_at", columns)

View file

@ -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()

View file

@ -277,7 +277,7 @@ class PublicCabinetTests(unittest.TestCase):
with self.SessionLocal() as db: with self.SessionLocal() as db:
raw_encrypted = db.execute(text("SELECT body FROM messages ORDER BY created_at DESC LIMIT 1")).scalar_one() 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) self.assertNotEqual(str(raw_encrypted), payload_body)
listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies) listed = self.chat_client.get("/api/public/chat/requests/TRK-CHAT-ENC/messages", cookies=cookies)

View file

@ -122,6 +122,7 @@ class PublicRequestCreateTests(unittest.TestCase):
"topic_code": "consulting", "topic_code": "consulting",
"description": "Тестируем создание заявки", "description": "Тестируем создание заявки",
"extra_fields": {"referral_name": "Партнер"}, "extra_fields": {"referral_name": "Партнер"},
"pdn_consent": True,
} }
response = self.client.post("/api/public/requests", json=payload) response = self.client.post("/api/public/requests", json=payload)
self.assertEqual(response.status_code, 401) self.assertEqual(response.status_code, 401)
@ -147,6 +148,9 @@ class PublicRequestCreateTests(unittest.TestCase):
self.assertEqual(created.status_code, "NEW") self.assertEqual(created.status_code, "NEW")
self.assertEqual(created.track_number, body["track_number"]) self.assertEqual(created.track_number, body["track_number"])
self.assertEqual(created.responsible, "Клиент") 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) client = db.get(Client, created.client_id)
self.assertIsNotNone(client) self.assertIsNotNone(client)
self.assertEqual(client.phone, payload["client_phone"]) 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") denied_other_track = self.client.get("/api/public/requests/TRK-OTHER")
self.assertEqual(denied_other_track.status_code, 403) 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): def test_view_request_can_use_phone_otp_and_switch_between_client_requests(self):
phone = "+79996660077" phone = "+79996660077"
with self.SessionLocal() as db: with self.SessionLocal() as db:
@ -299,6 +344,7 @@ class PublicRequestCreateTests(unittest.TestCase):
"topic_code": "consulting", "topic_code": "consulting",
"description": "Email auth mode create", "description": "Email auth mode create",
"extra_fields": {}, "extra_fields": {},
"pdn_consent": True,
}, },
) )
self.assertEqual(create.status_code, 201) self.assertEqual(create.status_code, 201)
@ -427,6 +473,7 @@ class PublicRequestCreateTests(unittest.TestCase):
"topic_code": "consulting", "topic_code": "consulting",
"description": "Проверка обязательного поля", "description": "Проверка обязательного поля",
"extra_fields": {}, "extra_fields": {},
"pdn_consent": True,
}, },
) )
self.assertEqual(missing.status_code, 400) self.assertEqual(missing.status_code, 400)
@ -440,6 +487,7 @@ class PublicRequestCreateTests(unittest.TestCase):
"topic_code": "consulting", "topic_code": "consulting",
"description": "Проверка обязательного поля", "description": "Проверка обязательного поля",
"extra_fields": {"passport_series": "1234"}, "extra_fields": {"passport_series": "1234"},
"pdn_consent": True,
}, },
) )
self.assertEqual(created.status_code, 201) 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(f"Max-Age={settings.PUBLIC_JWT_TTL_DAYS * 24 * 3600}", cookie_header)
self.assertIn("httponly", cookie_header.lower()) 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): def test_verify_view_otp_by_phone_sets_view_session_subject_as_phone(self):
phone = "+79998887766" phone = "+79998887766"
with self.SessionLocal() as db: with self.SessionLocal() as db:

View file

@ -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()

65
tests/test_s3_tls.py Normal file
View file

@ -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"))

View file

@ -159,6 +159,43 @@ class SecurityAuditTests(unittest.TestCase):
self.assertEqual(str(row.attachment_id), attachment_id) self.assertEqual(str(row.attachment_id), attachment_id)
self.assertEqual(row.scope, "REQUEST_ATTACHMENT") 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): def test_admin_object_proxy_denied_writes_security_deny_event(self):
fake_s3 = _FakeS3Storage() fake_s3 = _FakeS3Storage()
with self.SessionLocal() as db: with self.SessionLocal() as db:

View file

@ -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")

View file

@ -15,10 +15,13 @@ os.environ.setdefault("S3_SECRET_KEY", "test")
os.environ.setdefault("S3_BUCKET", "test") os.environ.setdefault("S3_BUCKET", "test")
from app.models.attachment import Attachment 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.message import Message
from app.models.notification import Notification from app.models.notification import Notification
from app.models.otp_session import OtpSession from app.models.otp_session import OtpSession
from app.models.request import Request from app.models.request import Request
from app.models.security_audit_log import SecurityAuditLog
from app.models.status import Status from app.models.status import Status
from app.models.status_history import StatusHistory from app.models.status_history import StatusHistory
from app.models.topic_status_transition import TopicStatusTransition 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) cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False)
OtpSession.__table__.create(bind=cls.engine) 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) Request.__table__.create(bind=cls.engine)
Attachment.__table__.create(bind=cls.engine) Attachment.__table__.create(bind=cls.engine)
Status.__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) Status.__table__.drop(bind=cls.engine)
Attachment.__table__.drop(bind=cls.engine) Attachment.__table__.drop(bind=cls.engine)
Request.__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) OtpSession.__table__.drop(bind=cls.engine)
cls.engine.dispose() cls.engine.dispose()
@ -76,6 +85,9 @@ class WorkerMaintenanceTaskTests(unittest.TestCase):
db.execute(delete(Notification)) db.execute(delete(Notification))
db.execute(delete(Attachment)) db.execute(delete(Attachment))
db.execute(delete(Request)) db.execute(delete(Request))
db.execute(delete(SecurityAuditLog))
db.execute(delete(AuditLog))
db.execute(delete(DataRetentionPolicy))
db.execute(delete(OtpSession)) db.execute(delete(OtpSession))
db.commit() db.commit()
@ -113,6 +125,167 @@ class WorkerMaintenanceTaskTests(unittest.TestCase):
self.assertEqual(len(remaining), 1) self.assertEqual(len(remaining), 1)
self.assertEqual(remaining[0].track_number, "TRK-ACT") 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): def test_cleanup_stale_uploads_removes_invalid_and_fixes_totals(self):
with self.SessionLocal() as db: with self.SessionLocal() as db:
req1 = Request( req1 = Request(