مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
25 KiB
چکلیست امنیتی — نسخه وب مستقل 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_keytest_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_authenticationtest_me_rejects_garbage_tokentest_admin_endpoints_require_authenticationtest_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_readabletest_foreign_member_list_is_not_readabletest_foreign_invite_is_rejectedtest_employee_of_another_tenant_is_not_readabletest_task_of_another_tenant_is_not_readabletest_repository_of_another_tenant_is_not_reachabletest_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_themselvestest_normal_user_cannot_create_providertest_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— متن پاسخ. - چگونه: در یک پاس بازبینی، دو نشت واقعی پیدا و بسته شد:
- متن پاسخ mock شامل
(provider=mock)بود — حذف شد. - نوع گام run
kind="model_output"بود — بهkind="output"تغییر کرد (کلمهٔ «model» در payload قابلمشاهدهٔ مشتری نشت میکرد).
- متن پاسخ mock شامل
- تست مدافع:
test_employee_payload_never_leaks_model_identitytest_run_payload_never_leaks_model_identitytest_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میبندد:یعنی حتی اگر policy اشتباه پیکربندی شود، سقف مطلق نمیشکند.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, ...) - تست مدافع:
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_attempttest_managed_host_rejects_nested_labeltest_managed_host_rejects_foreign_domaintest_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_repotest_ensure_repository_is_idempotenttest_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_apitest_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(خط ۱۵۷). - چگونه: دو کنترل مستقل:
- fail-closed روی کلید ناشناخته: اگر کلید ابزار در
KNOWN_TOOL_KEYSنباشد، درخواست با422رد میشود و ابزار هرگز ذخیره نمیشود (نه اینکه باallowed=Falseذخیره شود). - پیشفرض بسته: هر مجوز ذخیرهشده بهصورت پیشفرض
allowed=Falseاست و باید صریحاً روشن شود.
- برخی ابزارهای پرخطر (مثل
git_merge_pr,sandbox_destroy) نیازمند تأیید انسانی هستند (فیلد approval درToolSpec).
- fail-closed روی کلید ناشناخته: اگر کلید ابزار در
- تست مدافع:
test_unknown_tool_is_denied_by_default— هم422و هم عدمذخیرهسازی را تأیید میکند.test_creating_an_employee_seeds_a_prompt_and_tool_permissionstest_tool_permission_can_be_toggledtest_tools_catalog_requires_no_secretstest_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_dependenciestest_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_paginatedtest_projects_and_employees_accept_pagination
۱۹.۸ پاکسازی secretها در لاگ خطا
✅ هیچ خطایی بدون عبور از redactor ذخیره نمیشود
-
محل:
app/core/redact.py— فراخوانیشده ازapp/infrastructure/error_log.py. -
چگونه: هر خطا قبل از نوشتن در دیتابیس از سه لایه عبور میکند:
- ساختاری — کلیدی که معنایش secret است (
password،api_key،refresh_token،authorization، …) مقدارش کامل جایگزین میشود. - الگویی — مقادیر شناختنی مستقل از کلید: بلوک PEM، JWT،
scheme://user:pass@host،Bearer …،sk-…/ghp_…/AKIA…. - مقداری — خودِ secretهای تنظیمشده (
JWT_SECRET،ENCRYPTION_KEYو هر*_API_KEY/*_TOKEN) بهصورت تحتاللفظی جایگزین میشوند.
لایهٔ سوم مهمترین است: secretی که فقط با الگو تشخیص داده شود، دیر یا زود عیناً لاگ میشود؛ 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. - چگونه: کل
adminrouter با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روی ۹۰ فایل بدون خطا.