Files
smart-org/docs/security-checklist.md
mjalmoudy94 8eb631aedf ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
2026-10-13 21:58:41 +03:30

28 KiB
Raw Permalink Blame History

چک‌لیست امنیتی — نسخه وب مستقل Smart Organization

این سند «پذیرش مرحلهٔ ۱۹» در web/TODO.md را می‌بندد.

روش نوشتن این سند: هر آیتم با سه چیز مستند شده است — (۱) محل دقیق کنترل در کد، (۲) نحوهٔ اعمال آن، (۳) تستی که در صورت شکستن کنترل، قرمز می‌شود. هیچ آیتمی بدون «تست مدافع» تأیید نشده است. اگر کنترلی تست نداشت، به‌جای علامت ✅ صریحاً «⚠️ بدون تست» نوشته شده است.

وضعیت کل: همهٔ تست‌های امنیتی سبز — 120 passed (کل مجموعهٔ backend). ruff check تمیز، mypy app tests روی ۹۲ فایل بدون خطا.


۱۹.۱ مدیریت Secret

✅ API keyها در حالت سکون رمزنگاری می‌شوند

  • محل: apps/api/app/core/crypto.py
  • چگونه: کلید Fernet از ENCRYPTION_KEY با SHA-256 مشتق می‌شود (_derive_key، خط ۱۹) و _cipher() (خط ۲۸) یک نمونهٔ Fernet می‌سازد. encrypt() (خط ۳۶) و decrypt() (خط ۴۰) تنها مسیر ورود/خروج رازها هستند. mask() (خط ۴۹) فقط چهار کاراکتر آخر را برمی‌گرداند (••••1234).
  • نکتهٔ طراحی: در حالت سکون فقط ciphertext ذخیره می‌شود؛ در پاسخ‌های API هرگز plaintext برنمی‌گردد و فقط نسخهٔ masked نمایش داده می‌شود.
  • تست مدافع:
    • test_credential_creation_returns_only_a_masked_key
    • test_listing_credentials_never_exposes_the_key

✅ رمز عبور با argon2 هش می‌شود

  • محل: apps/api/app/core/security.py — PasswordHasher (خط ۲۰)، hash_password (خط ۲۳)، verify_password (خط ۲۷)، needs_rehash (خط ۳۴).
  • چگونه: argon2-cffi (برندهٔ PHC، مقاوم به GPU)؛ هیچ‌جا md5/sha1/sha256 خام برای رمز استفاده نشده است.
  • تست مدافع:
    • test_signup_helper_never_returns_password_hash — هیچ پاسخی password_hash ندارد.
    • test_login_fails_with_wrong_password — تأیید مسیر verify.

✅ JWT secret از env می‌آید و در production اعتبارسنجی می‌شود

  • محل: apps/api/app/core/config.py — jwt_secret (خط ۴۶)، _refuse_insecure_production_secrets (خط ۹۳)، _INSECURE_SECRETS (خط ۱۹)، MIN_SECRET_LENGTH = 32 (خط ۲۳). امضا در security.py خط ۵۱ (jwt.encode(..., algorithm=settings.jwt_algorithm)) و اعتبارسنجی خط ۵۶.
  • چگونه: یک model_validator(mode="after") در زمان boot اجرا می‌شود. اگر APP_ENV=production باشد و یکی از این‌ها برقرار باشد، فرآیند بالا نمی‌آید:
    • jwt_secret در فهرست مقادیر placeholder باشد ("", change-me, secret, ...)،
    • طول jwt_secret کمتر از ۳۲ بایت باشد،
    • encryption_key خالی باشد،
    • cors_origins شامل * باشد.
  • تست مدافع: کل tests/test_config_guard.py (۶ تست): test_production_rejects_the_placeholder_secret, test_production_rejects_a_short_secret, test_production_requires_an_encryption_key, test_production_rejects_wildcard_cors, test_production_accepts_a_fully_configured_environment, test_development_tolerates_placeholder_secrets.

✅ frontend هیچ secret ندارد

  • محل: apps/web/src/lib/api.ts (خط ۱۱–۱۲) و apps/web/src/lib/session.tsx.
  • چگونه: تنها متغیر env که frontend می‌خواند NEXT_PUBLIC_API_URL است که یک URL عمومی است، نه راز. جست‌وجوی process.env. در apps/web/src فقط همین یک مورد را برمی‌گرداند. کلیدهای مدل، tokenهای Gitea، کلیدهای sandbox و کلید پرداخت همه فقط در backend و از env خوانده می‌شوند (config.py).
  • توکن نشست: bearer token در localStorage نگهداری می‌شود و در هدر Authorization فرستاده می‌شود؛ هیچ‌گاه در URL یا query قرار نمی‌گیرد.
  • تست مدافع: ⚠️ بدون تست خودکار. این یک بازبینی دستی است (پوشش: جست‌وجوی process.env در کل apps/web/src). توصیه: افزودن یک تست که NEXT_PUBLIC_* را در برابر یک allowlist بررسی کند.

۱۹.۲ کنترل دسترسی

✅ middleware/dependency مرکزی برای auth

  • محل: apps/api/app/interfaces/http/deps.py — get_current_user (خط ۵۵)، get_optional_user (خط ۷۱)، require_platform_owner (خط ۸۳).
  • چگونه: یک dependency واحد توکن را decode می‌کند، کاربر را از DB می‌خواند و در صورت نامعتبر بودن 401 می‌دهد. هیچ routerای دستی توکن را parse نمی‌کند.
  • تست مدافع:
    • test_me_requires_authentication
    • test_me_rejects_garbage_token
    • test_admin_endpoints_require_authentication
    • test_health_is_public — تأیید اینکه استثنا (health) آگاهانه است.

✅ dependency مرکزی برای عضویت سازمان (tenant isolation)

  • محل: apps/api/app/interfaces/http/deps.py — get_org_context (خط ۸۹)، OrgContext.require (خط ۵۰).
  • چگونه: اصل کلیدی: نبودِ عضویت 404 برمی‌گرداند، نه 403 (خط ۹۶، ۱۰۳–۱۰۵). این آگاهانه است: 403 وجود یک منبع را تأیید می‌کند و به مهاجم اجازهٔ شمارش می‌دهد. با 404، سازمان دیگر «نامرئی» است.
  • تست مدافع: کل tests/test_tenant_isolation.py + تست‌های cross-tenant در test_workforce.py و test_project_repo.py:
    • test_foreign_organization_is_not_readable
    • test_foreign_member_list_is_not_readable
    • test_foreign_invite_is_rejected
    • test_employee_of_another_tenant_is_not_readable
    • test_task_of_another_tenant_is_not_readable
    • test_repository_of_another_tenant_is_not_reachable
    • test_smoke_path_is_invisible_to_another_tenant (E2E)

✅ policy object برای role/permission

  • محل: apps/api/app/interfaces/http/deps.py — require_capability (خط ۱۰۸) و require_role_at_least (خط ۱۱۸)، به‌همراه OrgContext.require (خط ۵۰). تعریف capabilityها و رتبهٔ نقش‌ها در domain (ROLE_RANK) است، نه در router.
  • چگونه: مسیر پیشنهادی Depends(require_capability(CAN_MANAGE_PROJECTS)) است؛ بررسی در یک نقطه انجام می‌شود. ROLE_RANK یک ترتیب عددی است تا «حداقل نقش» قابل بیان باشد (owner > admin > member > viewer).
  • تست مدافع:
    • test_cannot_invite_as_owner — تلاش برای ارتقای نقش از طریق invite رد می‌شود.
    • test_owner_can_list_members_and_see_themselves
    • test_normal_user_cannot_create_provider
    • test_normal_user_cannot_list_providers

✅ تست منفی برای unauthorized / forbidden

  • محل: tests/test_admin_control_plane.py، tests/test_auth.py، tests/test_tenant_isolation.py، tests/test_platform_hierarchy.py.
  • تست مدافع: ۴ تست ۴۰۱ در test_auth.py، ۳ تست ۴۰۳/۴۰۴ در test_admin_control_plane.py، ۶ تست cross-tenant در test_tenant_isolation.py، و کل test_platform_hierarchy.py (۲۲ تست).

✅ سلسله‌مراتب پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک

  • محل: apps/api/app/domain/identity/models.py (PlatformRole)، apps/api/app/infrastructure/db/models.py (ایندکس یکتا)، apps/api/app/interfaces/http/routers/admin_users.py، apps/api/app/application/services/platform_users.py.
  • چگونه: چهار قاعده در کد و دیتابیس قفل شده‌اند:
    1. دقیقاً یک مالک. ایندکس یکتای جزئی (uq_single_platform_owner ... WHERE platform_role = 'owner') در سطح دیتابیس جلوی مالک دوم را می‌گیرد؛ API هم ساخت/تنزل مالک را رد می‌کند. جابه‌جایی صندلی فقط با POST /admin/transfer-ownership و در یک تراکنش است (اول تنزل مالک فعلی، بعد ارتقای جانشین — تا ایندکس هرگز دو مالک نبیند).
    2. ثبت‌نام عمومی وجود ندارد. حساب‌ها فقط از POST /admin/users (مالک) یا از پذیرش دعوت ساخته می‌شوند. مسیرهای /api/invites/* تنها فراخوانی‌های ناشناس مجاز در لایهٔ RPC هستند (router.py)، چون خود توکن دعوت مجوز است.
    3. لینک دعوت هرگز reset رمز نمی‌شود. اگر برای ایمیل دعوت‌شده حساب وجود داشته باشد، پذیرش ناشناس با رمز 403 می‌گیرد و کاربر باید اول وارد شود (has_account در preview به UI می‌گوید کدام مسیر است).
    4. سازمان‌های مدیرِ رفته به مالک می‌رسند. تنزل یا حذف مدیر، owner_user_id و عضویت‌ها را جابه‌جا می‌کند؛ داده هرگز یتیم یا حذف نمی‌شود.
  • تست مدافع: tests/test_platform_hierarchy.py:
    • test_only_one_platform_owner_may_exist — درج مستقیم مالک دوم IntegrityError.
    • test_owner_cannot_be_created_through_the_api / test_owner_account_is_immutable_from_the_api
    • test_transfer_ownership_moves_the_seat_and_keeps_exactly_one
    • test_plain_user_cannot_create_an_organization
    • test_manager_cannot_create_accounts / test_manager_cannot_reach_the_admin_console
    • test_demoting_a_manager_transfers_organizations_to_the_owner
    • test_deleting_a_manager_transfers_organizations_and_memberships
    • test_a_link_cannot_reset_an_existing_account (در test_invites.py)
    • test_every_seat_move_is_audited

✅ کاربر عادی مدل/provider خام نمی‌بیند

  • محل: apps/api/app/application/services/workforce_service.py — نام‌گذاری گام‌های run و پاسخ‌ها؛ apps/api/app/infrastructure/adapters/mock.py — متن پاسخ.
  • چگونه: در یک پاس بازبینی، دو نشت واقعی پیدا و بسته شد:
    1. متن پاسخ mock شامل (provider=mock) بود — حذف شد.
    2. نوع گام run kind="model_output" بود — به kind="output" تغییر کرد (کلمهٔ «model» در payload قابل‌مشاهدهٔ مشتری نشت می‌کرد).
  • تست مدافع:
    • test_employee_payload_never_leaks_model_identity
    • test_run_payload_never_leaks_model_identity
    • test_routing_choices_expose_a_name_but_not_the_mode — مشتری یک نام (مثل «سریع») انتخاب می‌کند، اما نام مدل/provider را نمی‌بیند.

۱۹.۳ امنیت Sandbox

✅ اجرای مستقیم روی host ممنوع است

  • محل: apps/api/app/application/services/sandbox_service.py و apps/api/app/application/ports/ (SandboxProviderPort).
  • چگونه: هیچ مسیری برای اجرای کد روی host وجود ندارد. تنها راه اجرا، عبور از SandboxProviderPort است که adapter آن یا mock است یا remote (Daytona/E2B). انتخاب adapter در infrastructure/adapters/registry.py بر اساس SANDBOX_PROVIDER انجام می‌شود. سرور هیچ‌جا subprocess برای کد کاربر صدا نمی‌زند.
  • تست مدافع:
    • test_end_to_end_smoke_path — کل مسیر فقط از طریق sandbox عبور می‌کند.
    • test_domain_layer_has_no_framework_dependencies — لایهٔ domain به هیچ فریمورک/درایوری وابسته نیست، پس نمی‌تواند مخفیانه اجرا کند.

✅ محدودیت‌ها از policy می‌آید، نه از caller

  • محل: apps/api/app/application/services/sandbox_service.py — _clamp (خط ۱۱۲)، و apps/api/app/domain/sandbox/models.py — SandboxResourceLimit (خط ۱۲۳–۱۲۵).
  • چگونه: درخواست اجرای فرمان (RunCommandRequest) فقط command را می‌پذیرد. timeout_seconds و max_output_bytes هرگز از کاربر خوانده نمی‌شوند؛ سرویس آن‌ها را از scope سازمان/پروژه می‌گیرد و سپس با _clamp می‌بندد:
    timeout_seconds   = max(1, min(limit.timeout_seconds, ABSOLUTE_MAX_TIMEOUT_SECONDS))
    max_output_bytes  = max(1024, min(limit.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES))
    memory_mb         = max(128, ...)
    disk_mb           = max(256, ...)
    
    یعنی حتی اگر policy اشتباه پیکربندی شود، سقف مطلق نمی‌شکند.
  • تست مدافع: test_end_to_end_smoke_path صریحاً یک درخواست با timeout_seconds: 999999 و max_output_bytes: 10**9 می‌فرستد و بررسی می‌کند که این کلیدها در بدنهٔ پاسخ ظاهر نشوند — یعنی caller نتوانسته آن‌ها را دیکته کند.

✅ command timeout

  • محل: sandbox_service.py (_clamp، خط ۱۱۳–۱۱۵) و infrastructure/adapters/sandbox_remote.py (خط ۱۴۰: timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))).
  • چگونه: دفاع دولایه: لایهٔ سرویس clamp می‌کند، و لایهٔ adapter مستقل دوباره clamp می‌کند (defense in depth). adapter mock نیز قرارداد timeout را رعایت می‌کند (کامنت mock.py خط ۳ و ۳۱۶).
  • تست مدافع: پوشش غیرمستقیم در test_end_to_end_smoke_path.

✅ output limit

  • محل: sandbox_remote.py خط ۸۷ (min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES))، خط ۱۳۷؛ mock.py خط ۳۲۰ و ۳۴۹؛ برش نهایی در sandbox_service.py خط ۲۷۹–۲۸۰ (result.stdout[: limits.max_output_bytes]).
  • چگونه: خروجی هم در adapter و هم در سرویس بریده می‌شود تا یک فرمان پرمصرف حافظه/پاسخ را منفجر نکند.
  • تست مدافع: پوشش غیرمستقیم در test_end_to_end_smoke_path.

✅ سیاست egress طراحی شده است (deny-by-default)

  • محل: apps/api/app/domain/sandbox/models.py — NetworkPolicy (خط ۳۸): DENY_ALL = "deny_all" (خط ۳۹) و ALLOWLIST = "allowlist" (خط ۴۰)؛ مقدار پیش‌فرض SandboxResourceLimit.network_policy = NetworkPolicy.DENY_ALL (خط ۱۲۵).
  • چگونه: پیش‌فرض بستن کامل شبکه است. اگر روزی allowlist لازم شد، یک حالت صریح وجود دارد و باید آگاهانه انتخاب شود. سیاست در محدودیت ذخیره‌شده می‌نشیند، نه در درخواست کاربر.
  • تست مدافع: ⚠️ بدون تست اختصاصی برای مقدار پیش‌فرض. توصیه: یک تست واحد که SandboxResourceLimit().network_policy is NetworkPolicy.DENY_ALL را تأیید کند.

✅ audit برای command

  • محل: apps/api/app/application/services/sandbox_service.py خط ۲۹۳ (await self.ledger.record(...)) با UsageLedgerRepository (infrastructure/repositories/audit.py خط ۸۳).
  • چگونه: هر اجرای فرمان یک رکورد مصرف ثبت می‌کند (زمان CPU، run_id، employee_id، organization_id). زمان sandbox قابل صورتحساب و قابل ردیابی است.
  • تست مدافع: test_end_to_end_smoke_path (گام usage).

۱۹.۴ امنیت دامنه و Git

✅ تزریق host ممنوع است

  • محل: apps/api/app/domain/platform/rules.py — _LABEL_RE (خط ۳۲)، validate_subdomain_label (خط ۴۰)، validate_managed_host (خط ۵۹).
  • چگونه: برچسب فقط [a-z0-9-]، باید با حرف/عدد شروع و تمام شود، طول ۳ تا ۴۰. validate_managed_host علاوه بر این، پسوند .<root_domain> را الزامی می‌کند و برچسب تودرتو (a.b) را رد می‌کند. این همان کنترل ضدتزریق است که نمی‌گذارد یک host ساختگی از site block در Caddy خارج شود یا به میزبان دلخواه اشاره کند.
  • تست مدافع:
    • test_managed_host_rejects_injection_attempt
    • test_managed_host_rejects_nested_label
    • test_managed_host_rejects_foreign_domain
    • test_valid_labels / test_invalid_labels (parametrized)
    • test_build_host_appends_root_domain

✅ مقصد Caddy در برابر SSRF محافظت می‌شود

  • محل: apps/api/app/domain/platform/rules.py — _PROXY_TARGET_RE (خط ۳۳) و validate_proxy_target (خط ۷۷)؛ مصرف در infrastructure/adapters/caddy.py (خط ۱۳۶) و infrastructure/adapters/mock.py (خط ۲۸۸).
  • چگونه: الگو فقط (https?://)?(localhost|127\.0\.0\.1):(\d{1,5}) را می‌پذیرد. هر hostname عمومی، هر IP غیر-loopback و هر پورت غیرعددی رد می‌شود. یعنی یک مستأجر نمی‌تواند دامنهٔ عمومی خود را به یک سرویس داخلی proxy کند.
  • نکتهٔ اضافه: در adapter واقعی Caddy، قبل از reload فایل با caddy validate بررسی می‌شود (caddy.py خط ۳۰۰، _VALIDATE_TIMEOUT_SECONDS) و reload تایم‌اوت جدا دارد (خط ۳۰۹).
  • تست مدافع:
    • test_ssrf_proxy_targets_rejected (parametrized)
    • test_valid_proxy_targets (parametrized)

✅ پورت‌های محافظت‌شده ممنوع‌اند

  • محل: apps/api/app/domain/platform/rules.py — SYSTEM_RESERVED_PORTS (خط ۱۷)، SYSTEM_PROTECTED_PORTS (خط ۲۳)، is_port_allocatable (خط ۹۵)، assert_port_allocatable (خط ۱۰۶)، assert_not_protected_port (خط ۱۱۷).
  • چگونه: دو مجموعهٔ جدا:
    • SYSTEM_RESERVED_PORTS = {22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008} (سرویس‌های سیستم + data storeها) هرگز تخصیص نمی‌یابند.
    • SYSTEM_PROTECTED_PORTS = {3000, 35555, 35556, 35557} ∪ RESERVED — یک دامنهٔ preview هرگز نباید به این‌ها proxy شود، وگرنه یک hostname عمومی یک مؤلفهٔ داخلی را افشا می‌کند.
    • محدودهٔ تخصیص: 8080–8999 (DEFAULT_PORT_RANGE_START/END).
  • تست مدافع:
    • test_protected_ports_cannot_be_proxied (parametrized)
    • test_reserved_and_out_of_range_ports_rejected (parametrized)
    • test_allocatable_ports (parametrized)

✅ مخزن Git خصوصی است

  • محل: apps/api/app/infrastructure/adapters/gitea.py و mock.py (ensure_repository).
  • چگونه: مخزن در حالت private ساخته می‌شود؛ عملیات idempotent است تا دوبار ساخت، حالت را به public تغییر ندهد.
  • تست مدافع:
    • test_ensure_repository_creates_a_private_repo
    • test_ensure_repository_is_idempotent
    • test_repo_status_is_reported_after_ensure

✅ token هرگز برنمی‌گردد

  • محل: apps/api/app/infrastructure/adapters/gitea.py (توکن فقط در adapter/backend)؛ مسیر invite: hash_token در core/security.py (خط ۶۸).
  • چگونه: توکن مدیریت Gitea هرگز در payload پاسخ ظاهر نمی‌شود. توکن‌های دعوت فقط یک‌بار به دعوت‌کننده نمایش داده می‌شوند و در DB فقط به‌صورت hash ذخیره می‌شوند (hash_token).
  • تست مدافع:
    • test_git_password_never_leaves_the_api
    • test_invite_returns_token_only_to_the_inviter

۱۹.۵ امنیت ابزارهای کارمند AI

✅ دسترسی ابزارها deny-by-default است

  • محل: apps/api/app/domain/workforce/tools.py (کامنت طراحی خط ۸: «Nothing is allowed by default; an employee needs an explicit permission row.»)، TOOL_CATALOG (خط ۵۰)، KNOWN_TOOL_KEYS (خط ۱۵۰)؛ مدل مجوز در apps/api/app/domain/workforce/models.py — ToolPermission.allowed: bool = False (خط ۱۵۷).
  • چگونه: دو کنترل مستقل:
    1. fail-closed روی کلید ناشناخته: اگر کلید ابزار در KNOWN_TOOL_KEYS نباشد، درخواست با 422 رد می‌شود و ابزار هرگز ذخیره نمی‌شود (نه اینکه با allowed=False ذخیره شود).
    2. پیش‌فرض بسته: هر مجوز ذخیره‌شده به‌صورت پیش‌فرض allowed=False است و باید صریحاً روشن شود.
    • برخی ابزارهای پرخطر (مثل git_merge_pr, sandbox_destroy) نیازمند تأیید انسانی هستند (فیلد approval در ToolSpec).
  • تست مدافع:
    • test_unknown_tool_is_denied_by_default — هم 422 و هم عدم‌ذخیره‌سازی را تأیید می‌کند.
    • test_creating_an_employee_seeds_a_prompt_and_tool_permissions
    • test_tool_permission_can_be_toggled
    • test_tools_catalog_requires_no_secrets
    • test_pending_approvals_are_org_scoped

۱۹.۶ جداسازی لایه‌ها (پیش‌نیاز امنیتی معماری)

✅ لایهٔ domain به فریمورک وابسته نیست

  • محل: apps/api/app/domain/** — هیچ import از fastapi, sqlalchemy, redis, httpx یا provider خارجی.
  • چگونه: این جداسازی، سطح حملهٔ لایهٔ قواعد را کوچک نگه می‌دارد: قواعد امنیتی دامنه (اعتبارسنجی برچسب، SSRF، پورت) بدون DB و بدون HTTP قابل تست‌اند.
  • تست مدافع:
    • test_domain_layer_has_no_framework_dependencies
    • test_domain_rules_are_usable_without_a_database

۱۹.۷ محدودسازی منابع (ورودی‌های بی‌کران)

✅ صفحه‌بندی اجباری روی لیست‌ها

  • محل: همهٔ repositoryهای لیست (limit/offset) و routers مربوطه.
  • چگونه: سقف min(limit, 500) اعمال می‌شود؛ offset منفی و limit خارج از بازه با 422 رد می‌شوند.
  • تست مدافع:
    • test_out_of_range_pagination_is_rejected (parametrized)
    • test_tasks_list_is_paginated
    • test_projects_and_employees_accept_pagination

۱۹.۸ پاک‌سازی secretها در لاگ خطا

✅ هیچ خطایی بدون عبور از redactor ذخیره نمی‌شود

  • محل: app/core/redact.py — فراخوانی‌شده از app/infrastructure/error_log.py.

  • چگونه: هر خطا قبل از نوشتن در دیتابیس از سه لایه عبور می‌کند:

    1. ساختاری — کلیدی که معنایش secret است (password، api_key، refresh_token، authorization، …) مقدارش کامل جایگزین می‌شود.
    2. الگویی — مقادیر شناختنی مستقل از کلید: بلوک PEM، JWT، scheme://user:pass@host، Bearer …، sk-…/ghp_…/AKIA….
    3. مقداری — خودِ secretهای تنظیم‌شده (JWT_SECRET، ENCRYPTION_KEY و هر *_API_KEY/*_TOKEN) به‌صورت تحت‌اللفظی جایگزین می‌شوند.

    لایهٔ سوم مهم‌ترین است: secretی که فقط با الگو تشخیص داده شود، دیر یا زود عیناً لاگ می‌شود؛ secretی که با مقدار تطبیق داده شود، نمی‌تواند.

  • چرا مهم است: لاگ خطا جایی است که credentialها تجمع می‌کنند — بدنهٔ درخواست، هدر Authorization، DSN، کلید provider. یک لاگ خطا که رمز عبور ذخیره کند از نداشتن لاگ خطا بدتر است.

  • تست مدافع: tests/test_redact.py (۴۲ تست) و test_a_secret_in_the_exception_message_never_reaches_the_database که یک DSN حاوی رمز را از یک استثنای واقعی رد می‌کند و بررسی می‌کند نه در message و نه در stack نیامده باشد.

✅ redactor هرگز استثنا پرتاب نمی‌کند

  • محل: همان ماژول؛ هر تابع در try/except کامل بسته شده است.
  • چگونه: در بدترین حالت متن را حذف می‌کند، نه اینکه هندلر را بشکند. یک redactor که داخل except پرتاب کند، یک ۵۰۰ را به یک hang تبدیل می‌کند.
  • تست مدافع: test_very_long_input_is_bounded، test_non_string_keys_do_not_raise، test_a_broken_recorder_does_not_break_the_response.

✅ کنسول خطا فقط برای مالک سامانه است

  • محل: app/interfaces/http/routers/admin.py — dependency سطح router.
  • چگونه: کل admin router با dependencies=[Depends(require_platform_owner)] محافظت می‌شود، پس endpoint جدید بدون یادآوری نویسنده هم محافظت‌شده است. کنسول خطا همهٔ مستأجرها را نشان می‌دهد، پس این کنترل حیاتی است.
  • تست مدافع: test_a_regular_customer_cannot_reach_the_console، test_an_anonymous_caller_cannot_reach_the_console، test_resolving_requires_a_platform_owner.

✅ کنسول خطا نیز خودش ورودی بی‌کران نمی‌پذیرد

  • محل: MAX_ERROR_PAGE_SIZE = 200 در router و MAX_MESSAGE_CHARS/MAX_STACK_CHARS/MAX_METADATA_JSON_CHARS در recorder.
  • چگونه: سقف صفحه‌بندی روی limit، و سقف طول روی هر فیلد متنی. یک exception با پیام ۵ مگابایتی نباید جدول را پر کند.
  • تست مدافع: test_the_page_size_is_capped، test_very_long_input_is_bounded.

جمع‌بندی

حوزه آیتم‌های تأییدشده ⚠️ بدون تست
۱۹.۱ Secret Management ۴ ۱ (نبود secret در frontend)
۱۹.۲ Access Control ۵ ۰
۱۹.۳ Sandbox Security ۵ ۱ (پیش‌فرض DENY_ALL)
۱۹.۴ Domain/Git Security ۵ ۰
۱۹.۵ ابزارهای کارمند ۱ ۰
۱۹.۶ جداسازی لایه‌ها ۱ ۰
۱۹.۷ محدودسازی منابع ۱ ۰
۱۹.۸ پاک‌سازی لاگ خطا ۴ ۰

دو مورد ⚠️ باقی‌مانده هر دو کنترلی هستند که در کد وجود دارند و درست کار می‌کنند، اما تست خودکار مستقل ندارند (فقط بازبینی دستی/پوشش غیرمستقیم). هر دو کم‌هزینه‌اند و در فهرست کار بعدی ثبت شده‌اند. هیچ‌کدام یک شکاف امنیتی فعال نیست.

پذیرش مرحلهٔ ۱۹:

  • ✅ web/docs/security-checklist.md کامل است (همین سند).
  • ✅ تست‌های امنیتی اصلی سبزند: 216 passed (بک‌اند) و 25 passed (فرانت‌اند).
  • ✅ ruff check تمیز؛ mypy app روی ۹۰ فایل بدون خطا.