نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
# چکلیست امنیتی — نسخه وب مستقل AGY
|
||||
|
||||
> این سند «پذیرش مرحلهٔ ۱۹» در `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` علاوه بر این، پسوند `.<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` روی ۹۰ فایل بدون خطا.
|
||||
Reference in New Issue
Block a user