# چک‌لیست امنیتی — نسخه وب مستقل 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_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`. - **تست مدافع:** ۴ تست ۴۰۱ در `test_auth.py`، ۳ تست ۴۰۳/۴۰۴ در `test_admin_control_plane.py`، ۶ تست cross-tenant در `test_tenant_isolation.py`. ### ✅ کاربر عادی مدل/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` علاوه بر این، پسوند `.` را الزامی می‌کند و **برچسب تودرتو** (`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` روی ۹۰ فایل بدون خطا.