add security test 04

This commit is contained in:
TronoSfera 2026-03-02 17:18:10 +03:00
parent 9eeecb48a3
commit 61dc621501
15 changed files with 241 additions and 13 deletions

View file

@ -91,6 +91,7 @@ OTP_SMS_MIN_BALANCE=20
# Email OTP / fallback
# EMAIL_PROVIDER: dummy | smtp | service
# ----------------------------------------------------------------------------
EMAIL_SERVICE_ENABLED=true
EMAIL_PROVIDER=service
EMAIL_SERVICE_URL=http://email-service:8010
OTP_EMAIL_FALLBACK_ENABLED=true
@ -133,3 +134,12 @@ CLAMAV_ENABLED=true
CLAMAV_HOST=clamav
CLAMAV_PORT=3310
CLAMAV_TIMEOUT_SECONDS=20
# ----------------------------------------------------------------------------
# Security scheduler (dedicated periodic smoke entity)
# ----------------------------------------------------------------------------
SECURITY_SCHEDULER_INTERVAL_SECONDS=900
SECURITY_SCHEDULER_INTERNAL_BASE_URL=http://frontend
SECURITY_SCHEDULER_EXTERNAL_DOMAINS=ruakb.ru,ruakb.online
SECURITY_SCHEDULER_SKIP_DOCKER_CHECKS=1
SECURITY_SCHEDULER_RUN_INCIDENT_ON_FAIL=1

View file

@ -1,6 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y build-essential curl openssl ca-certificates && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

View file

@ -4,7 +4,7 @@
prod-up prod-down prod-logs prod-ps prod-migrate \
prod-secrets-generate prod-secrets-apply \
prod-minio-tls-init incident-checklist rotate-encryption-kid reencrypt-active-kid \
security-smoke prod-security-audit \
security-smoke prod-security-audit prod-security-scheduler-up prod-security-scheduler-logs \
prod-cert-init prod-cert-renew \
check-prod-files check-cert-files \
run migrate test seed-quotes
@ -44,6 +44,8 @@ help:
@echo " incident-checklist - Create PDn incident checklist markdown report"
@echo " security-smoke - Run security smoke checks and create report"
@echo " prod-security-audit - Full production security audit/repair workflow"
@echo " prod-security-scheduler-up - Start/update dedicated security scheduler service"
@echo " prod-security-scheduler-logs - Tail security scheduler logs"
@echo " rotate-encryption-kid - Add new KID key pair to .env and switch active KID"
@echo " reencrypt-active-kid - Re-encrypt historical encrypted fields using active KID"
@echo " prod-cert-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)"
@ -130,6 +132,17 @@ prod-security-audit: check-cert-files
LOCAL_SMOKE_CANDIDATES="$(LOCAL_SMOKE_CANDIDATES)" \
./scripts/ops/prod_security_audit.sh
prod-security-scheduler-up: check-prod-files
@echo "[SEC] Checking MinIO TLS bundle"
@if [ ! -f deploy/tls/minio/ca.crt ] || ! openssl x509 -in deploy/tls/minio/ca.crt -noout >/dev/null 2>&1 || [ ! -f deploy/tls/minio/public.crt ] || ! openssl x509 -in deploy/tls/minio/public.crt -noout >/dev/null 2>&1 || [ ! -f deploy/tls/minio/private.key ]; then \
echo "[SEC] MinIO TLS bundle missing/invalid -> regenerating"; \
MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh; \
fi
$(PROD_COMPOSE) up -d --build --force-recreate security-scheduler
prod-security-scheduler-logs: check-prod-files
$(PROD_COMPOSE) logs -f --tail=200 security-scheduler
rotate-encryption-kid:
./scripts/ops/rotate_encryption_kid.sh --env-file .env

View file

@ -169,7 +169,8 @@ OTP sending is implemented through a dedicated SMS service layer (`app/services/
Public auth mode can be selected via environment:
```bash
PUBLIC_AUTH_MODE=sms # sms | email | sms_or_email | totp
EMAIL_PROVIDER=dummy # dummy | smtp
EMAIL_SERVICE_ENABLED=true # false -> email-service stays healthy but sending is disabled
EMAIL_PROVIDER=dummy # dummy | smtp | service
EMAIL_SERVICE_URL=http://email-service:8010
INTERNAL_SERVICE_TOKEN=change_me_internal_service_token
OTP_EMAIL_FALLBACK_ENABLED=true
@ -204,11 +205,17 @@ OTP_EMAIL_TEMPLATE=Ваш код подтверждения: {code}
For dedicated email microservice (recommended in production):
```bash
EMAIL_SERVICE_ENABLED=true
EMAIL_PROVIDER=service
EMAIL_SERVICE_URL=http://email-service:8010
INTERNAL_SERVICE_TOKEN=<strong-random-token>
```
To keep infrastructure healthy but disable email sending temporarily:
```bash
EMAIL_SERVICE_ENABLED=false
```
Admin/Lawyer TOTP endpoints:
- `GET /api/admin/auth/totp/status`
- `POST /api/admin/auth/totp/setup`
@ -371,6 +378,31 @@ You can override:
make prod-security-audit LOCAL_SMOKE_CANDIDATES="https://127.0.0.1,http://127.0.0.1"
```
## Dedicated security scheduler entity
`security-scheduler` is a separate container service that runs periodic smoke checks automatically.
Start/update:
```bash
make prod-security-scheduler-up
```
Logs:
```bash
make prod-security-scheduler-logs
```
Main env knobs:
```bash
SECURITY_SCHEDULER_INTERVAL_SECONDS=900
SECURITY_SCHEDULER_INTERNAL_BASE_URL=http://frontend
SECURITY_SCHEDULER_EXTERNAL_DOMAINS=ruakb.ru,ruakb.online
SECURITY_SCHEDULER_SKIP_DOCKER_CHECKS=1
SECURITY_SCHEDULER_RUN_INCIDENT_ON_FAIL=1
```
Scheduler script:
- `/Users/tronosfera/Develop/Law/scripts/ops/security_scheduler.sh`
## Container health and alerting
Docker Compose is configured with:
- `restart: unless-stopped` for core services

View file

@ -65,6 +65,7 @@ class Settings(BaseSettings):
OTP_AUTOTEST_FORCE_MOCK_SMS: bool = True
PUBLIC_AUTH_MODE: str = "sms" # sms | email | sms_or_email | totp
EMAIL_PROVIDER: str = "dummy" # dummy | smtp
EMAIL_SERVICE_ENABLED: bool = True
EMAIL_SERVICE_URL: str = "http://email-service:8010"
INTERNAL_SERVICE_TOKEN: str = "change_me_internal_service_token"
SMTP_HOST: str = ""

View file

@ -17,16 +17,24 @@ class InternalEmailSend(BaseModel):
@app.on_event("startup")
def _validate_security_config_on_startup() -> None:
if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)):
return
validate_production_security_or_raise("email-service")
@app.get("/health")
def health():
return {"status": "ok", "service": "email-service"}
return {
"status": "ok",
"service": "email-service",
"enabled": bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)),
}
@app.post("/internal/send-otp")
def internal_send_otp(payload: InternalEmailSend, x_internal_token: str | None = Header(default=None)):
if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)):
raise HTTPException(status_code=503, detail="Email service disabled")
expected = str(settings.INTERNAL_SERVICE_TOKEN or "").strip()
if not expected:
raise HTTPException(status_code=500, detail="INTERNAL_SERVICE_TOKEN не настроен")

View file

@ -139,6 +139,9 @@ def _send_via_email_service(*, email: str, subject: str, body: str) -> dict[str,
def send_otp_email_message(*, email: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]:
if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)):
raise EmailDeliveryError("Email-рассылка отключена (EMAIL_SERVICE_ENABLED=false)")
normalized_email = _normalize_email(email)
if not normalized_email:
raise EmailDeliveryError("Некорректный email")
@ -163,6 +166,17 @@ def send_otp_email_message(*, email: str, code: str, purpose: str, track_number:
def email_provider_health() -> dict[str, Any]:
if not bool(getattr(settings, "EMAIL_SERVICE_ENABLED", True)):
return {
"provider": "disabled",
"status": "ok",
"mode": "disabled",
"dev_mode": bool(_otp_dev_mode_enabled()),
"can_send": False,
"checks": {"email_service_enabled": False},
"issues": ["EMAIL_SERVICE_ENABLED=false: Email-рассылка отключена"],
}
provider = str(settings.EMAIL_PROVIDER or "dummy").strip().lower()
if _otp_dev_mode_enabled():
return {

View file

@ -115,6 +115,7 @@ echo $? # 0=OK, >0=ALERT
| SEC-14 | Контроль уязвимостей в CI | `.github/workflows/security-ci.yml` (`bandit`, `pip-audit`, `trivy`) | GitHub Actions: workflow `security-ci` (PR/push/schedule/manual). Локальная валидация YAML: `ruby -e "require 'yaml'; YAML.load_file('.github/workflows/security-ci.yml')"` |
| SEC-15 | Регулярный security smoke | `scripts/ops/security_smoke.sh` | `make security-smoke` или `./scripts/ops/security_smoke.sh https://ruakb.online`; проверять отчет `reports/security/security-smoke-<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) |
| SEC-AUTO | Автоматизация security-smoke отдельной сущностью | `scripts/ops/security_scheduler.sh`, service `security-scheduler` в compose | `make prod-security-scheduler-up`; далее `make prod-security-scheduler-logs` и проверка health статуса контейнера |
| P49 | Клиентский UI запросов (куратор/смена юриста) | e2e `e2e/tests/service_requests_flow.spec.js`, `e2e/tests/public_client_flow.spec.js` | `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js e2e/tests/public_client_flow.spec.js` |
| P50 | Админ UI: вкладка `Запросы` + topbar индикатор | `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, e2e `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_metrics_templates tests.admin.test_service_requests -v` + Playwright прогон указанных spec |
| P51 | Тесты контура запросов | backend: `tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`; e2e: `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js` |

View file

@ -82,6 +82,12 @@ services:
security_opt:
- no-new-privileges:true
security-scheduler:
volumes:
- ./reports:/app/reports
security_opt:
- no-new-privileges:true
# Production: keep official ClamAV image on x86_64 hosts.
clamav:
platform: linux/amd64

View file

@ -72,6 +72,12 @@ services:
security_opt:
- no-new-privileges:true
security-scheduler:
volumes:
- ./reports:/app/reports
security_opt:
- no-new-privileges:true
# Production: keep official ClamAV image on x86_64 hosts.
clamav:
platform: linux/amd64

View file

@ -122,6 +122,30 @@ services:
command: ["celery","-A","app.workers.celery_app:celery_app","beat","-l","INFO"]
volumes: [".:/app"]
security-scheduler:
build: .
container_name: law-security-scheduler
restart: unless-stopped
env_file: .env
depends_on:
frontend:
condition: service_healthy
backend:
condition: service_healthy
chat-service:
condition: service_healthy
email-service:
condition: service_healthy
command: ["bash", "-lc", "./scripts/ops/security_scheduler.sh"]
healthcheck:
test: ["CMD-SHELL", "test -f /tmp/security_scheduler_heartbeat && [ $(( $(date +%s) - $(cat /tmp/security_scheduler_heartbeat) )) -lt 1800 ]"]
interval: 60s
timeout: 5s
retries: 3
start_period: 60s
volumes:
- .:/app
db:
image: postgres:16
container_name: law-db

View file

@ -10,14 +10,29 @@ OVERWRITE="${MINIO_TLS_OVERWRITE:-false}"
mkdir -p "$OUT_DIR"
if [[ "$OVERWRITE" != "true" ]]; then
for required in ca.crt ca.key public.crt private.key; do
if [[ -f "$OUT_DIR/$required" ]]; then
echo "[ERROR] $OUT_DIR/$required already exists. Set MINIO_TLS_OVERWRITE=true to regenerate." >&2
exit 1
prepare_output_path() {
local path="$1"
if [[ -d "$path" ]]; then
if [[ "$OVERWRITE" == "true" ]]; then
rm -rf "$path"
return 0
fi
done
fi
echo "[ERROR] $path is a directory. Set MINIO_TLS_OVERWRITE=true to replace it." >&2
exit 1
fi
if [[ -f "$path" ]]; then
if [[ "$OVERWRITE" == "true" ]]; then
rm -f "$path"
return 0
fi
echo "[ERROR] $path already exists. Set MINIO_TLS_OVERWRITE=true to regenerate." >&2
exit 1
fi
}
for required in ca.crt ca.key public.crt private.key; do
prepare_output_path "$OUT_DIR/$required"
done
if ! command -v openssl >/dev/null 2>&1; then
echo "[ERROR] openssl not found" >&2

View file

@ -62,7 +62,19 @@ ensure_env_file() {
ensure_minio_tls_bundle() {
if file_missing "deploy/tls/minio/public.crt" || file_missing "deploy/tls/minio/private.key" || file_missing "deploy/tls/minio/ca.crt"; then
log "MinIO TLS bundle is missing -> generating"
./scripts/ops/minio_tls_bootstrap.sh
MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh
return 0
fi
if ! openssl x509 -in "deploy/tls/minio/ca.crt" -noout >/dev/null 2>&1; then
log "MinIO CA certificate is invalid -> regenerating"
MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh
return 0
fi
if ! openssl x509 -in "deploy/tls/minio/public.crt" -noout >/dev/null 2>&1; then
log "MinIO public certificate is invalid -> regenerating"
MINIO_TLS_OVERWRITE=true ./scripts/ops/minio_tls_bootstrap.sh
else
log "MinIO TLS bundle present"
fi
@ -82,12 +94,13 @@ stack_up_and_migrate() {
"${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate db redis minio clamav
log "Starting app services (backend/chat/email/worker/beat)"
"${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate backend chat-service email-service worker beat
"${PROD_COMPOSE[@]}" up -d --build --remove-orphans --force-recreate backend chat-service email-service worker beat security-scheduler
log "Waiting app services to become healthy"
wait_service_healthy "backend" 60
wait_service_healthy "chat-service" 60
wait_service_healthy "email-service" 60
wait_service_healthy "security-scheduler" 90
# Force recreate frontend/edge after chat/backend to avoid stale DNS upstream cache in nginx.
log "Starting/recreating frontend and edge"

View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -u
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$ROOT_DIR"
INTERVAL_SECONDS="${SECURITY_SCHEDULER_INTERVAL_SECONDS:-900}"
INTERNAL_BASE_URL="${SECURITY_SCHEDULER_INTERNAL_BASE_URL:-http://frontend}"
EXTERNAL_DOMAINS="${SECURITY_SCHEDULER_EXTERNAL_DOMAINS:-ruakb.ru,ruakb.online}"
SKIP_DOCKER_CHECKS="${SECURITY_SCHEDULER_SKIP_DOCKER_CHECKS:-1}"
RUN_INCIDENT_ON_FAIL="${SECURITY_SCHEDULER_RUN_INCIDENT_ON_FAIL:-1}"
HEARTBEAT_FILE="${SECURITY_SCHEDULER_HEARTBEAT_FILE:-/tmp/security_scheduler_heartbeat}"
log() {
echo "[SEC-SCHEDULER] $*"
}
run_smoke() {
local url="$1"
if SECURITY_SMOKE_SKIP_DOCKER_CHECKS="$SKIP_DOCKER_CHECKS" ./scripts/ops/security_smoke.sh "$url"; then
log "smoke ok: ${url}"
return 0
fi
log "smoke failed: ${url}"
return 1
}
run_cycle() {
local failed=0
run_smoke "$INTERNAL_BASE_URL" || failed=1
IFS=',' read -r -a _domains <<< "$EXTERNAL_DOMAINS"
local domain
for domain in "${_domains[@]}"; do
domain="$(echo "$domain" | xargs)"
[[ -z "$domain" ]] && continue
run_smoke "https://${domain}" || failed=1
done
date +%s > "$HEARTBEAT_FILE"
if [[ "$failed" == "1" && "$RUN_INCIDENT_ON_FAIL" == "1" && -x "./scripts/ops/incident_checklist.sh" ]]; then
./scripts/ops/incident_checklist.sh \
--severity MEDIUM \
--category MONITORING_ALERT \
--summary "security-scheduler detected smoke check failure" || true
fi
}
validate_interval() {
if ! [[ "$INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || [[ "$INTERVAL_SECONDS" -lt 60 ]]; then
log "invalid SECURITY_SCHEDULER_INTERVAL_SECONDS=${INTERVAL_SECONDS}, fallback to 900"
INTERVAL_SECONDS=900
fi
}
main() {
validate_interval
mkdir -p reports/security reports/incidents
log "started: interval=${INTERVAL_SECONDS}s internal=${INTERNAL_BASE_URL} external=${EXTERNAL_DOMAINS}"
while true; do
local started_at
started_at="$(date +%s)"
run_cycle
local elapsed
elapsed="$(( $(date +%s) - started_at ))"
local sleep_for
sleep_for="$(( INTERVAL_SECONDS - elapsed ))"
if (( sleep_for < 1 )); then
sleep_for=1
fi
sleep "$sleep_for"
done
}
main "$@"

View file

@ -6,6 +6,7 @@ cd "$ROOT_DIR"
BASE_URL="${1:-http://localhost:8081}"
REPORT_DIR="${REPORT_DIR:-reports/security}"
SECURITY_SMOKE_SKIP_DOCKER_CHECKS="${SECURITY_SMOKE_SKIP_DOCKER_CHECKS:-0}"
TS_HUMAN="$(date -u +"%Y-%m-%d %H:%M:%S UTC")"
TS_FILE="$(date -u +"%Y%m%d-%H%M%S")"
REPORT_FILE="${REPORT_DIR}/security-smoke-${TS_FILE}.md"
@ -166,6 +167,10 @@ check_cookie_and_security_flags() {
}
check_compose_service_running() {
if [[ "$SECURITY_SMOKE_SKIP_DOCKER_CHECKS" == "1" ]]; then
add_warn "docker checks disabled by SECURITY_SMOKE_SKIP_DOCKER_CHECKS=1"
return 0
fi
local service="$1"
if ! command -v docker >/dev/null 2>&1; then
add_warn "docker is not available: service checks skipped"
@ -182,6 +187,10 @@ check_compose_service_running() {
}
check_db_security_audit_table() {
if [[ "$SECURITY_SMOKE_SKIP_DOCKER_CHECKS" == "1" ]]; then
add_warn "db checks disabled by SECURITY_SMOKE_SKIP_DOCKER_CHECKS=1"
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
add_warn "docker is not available: DB checks skipped"
return 0