Files
clinicpro/.claude/prompt/security-audit-delta-2026-08.md
T
hamed 6876135a53 feat: add BlogBodySanitizer for HTML sanitization on article save
- Implemented BlogBodySanitizer to clean HTML content before saving articles, ensuring security against XSS attacks.
- Added tests for BlogBodySanitizer to verify that unsafe tags and attributes are stripped from the content.
- Introduced ApiLeastPrivilegeTest to ensure that unauthorized users cannot access sensitive API routes, maintaining strict access control.
2026-08-07 21:13:38 +03:30

521 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# آدیت امنیتی دلتایی — سطح حملهٔ ساخته‌شده بعد از ۲۰۲۶-۰۷-۱۹
## زمینه
آخرین آدیت امنیتی `clinicpro` در `docs/security/AUDIT-2026-07-19.md` ثبت شده. از آن تاریخ تا
امروز (۲۰۲۶-۰۸-۰۷) روی این repo **۳۵۴ کامیت** زده شده و تمرکز غالب آن‌ها دقیقاً روی سطحی است
که آدیت قبلی ندیده بود:
- رجیستری واحد مجوزها (`PermissionCatalog`) و اندپوینت `GET /api/v1/permission-catalog`
- بازنویسی گیت‌های منشی و پزشکِ مهمانِ کلینیک
- دامنهٔ کاملاً جدید `Treatment` — پروندهٔ درمان، پروتکل، اجرای جلسه توسط پرسنل
- نقش/پنل `staff` با گاردِ متمرکز `StaffRouteGuardSubscriber`
- `PatientRecordScopeResolver` برای محدودکردن دید پرونده‌ها
آدیت قبلی خودش در بخش «محدودیت پوشش» نوشته بود که ماتریس authz ناقص مانده، چون فقط کاربر
`admin` و `doctor` در DB بود. آن محدودیت حالا برطرف‌شدنی است.
این پرامپت **آدیت کامل از صفر نیست**. عمداً دلتایی است: یافته‌های قبلی فقط regression می‌شوند،
و بودجهٔ اصلی صرف کدی می‌شود که هرگز آدیت نشده.
## مشکل / هدف
**مشکل:** بزرگ‌ترین سطح حملهٔ فعلی پروژه — authorization چندنقشی و دامنهٔ `Treatment` — هیچ‌وقت
تست امنیتی نشده. یک رجیستری مجوز که در UI رندر می‌شود ولی در backend enforce نشود، یعنی هر
منشی/پزشکِ مهمان می‌تواند با یک درخواست مستقیم به API از مجوزش فرار کند.
**هدف:** پیدا کردن و رفع آسیب‌پذیری‌های این سطح، به‌علاوهٔ تأیید اینکه یافته‌های آدیت قبلی
برنگشته‌اند.
**سیاست رفع (تصمیم کاربر):**
- 🟥 Critical و 🟧 High: **همان جلسه خودکار رفع شود** + تست رگرسیون نوشته شود.
- 🟨 Medium و پایین‌تر: **اول گزارش، بعد تأیید کاربر، بعد رفع.** بدون تأیید دست نزن.
**خارج از محدوده (تصمیم کاربر):** مهاجرت CKEditor از `@ckeditor/ckeditor5-build-classic` به
پکیج umbrella `ckeditor5` v45+. فقط به‌عنوان «risk پذیرفته‌شده» در گزارش ثبت شود، پیاده نشود.
## معیار پذیرش
-**موفق:** گزارش `docs/security/AUDIT-2026-08-07.md` تولید شده و برای **هر** یافته یک بازتولید
اجراشده دارد (دستور + خروجی واقعی). هر ردیف `PermissionCatalog::RESOURCES` یک تست دارد که
ثابت می‌کند خاموش‌بودن آن مجوز، درخواستِ متناظر API را با **403** رد می‌کند — نه اینکه فقط
دکمه را در UI پنهان کند.
-**خطا:** درخواست به هر اندپوینت `/api/v1/treatment-*` با توکن کاربری از tenant دیگر →
**403 یا 404** با envelope خطای `BaseController` و کد از `ErrorCodes`؛ هرگز 200 با دادهٔ
tenant دیگر و هرگز 500 با stack trace.
- ⚠️ **مرزی:** کاربر چندنقشی (مثلاً هم `ROLE_STAFF` هم `ROLE_SECRETARY`) پس از
`POST /api/v1/auth/switch-context` دقیقاً دسترسی همان context فعال را دارد، نه اجتماع دو
نقش. همچنین کاربری که **فقط** `ROLE_STAFF` است، روی هر مسیر خارج از allowlist ــ از جمله
مسیرهایی که بعد از نوشتن گارد اضافه شده‌اند ــ 403 می‌گیرد.
- ✅ تست‌ها سبزند: `ddev exec php bin/phpunit` و `ddev exec php vendor/bin/phpstan analyse`.
- ✅ اگر رفتار یا قرارداد هر endpoint عوض شد، فایل متناظر در `docs/api/` همان جلسه به‌روز شد.
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `../.claude/skills/symfony-security-audit/driver.mjs` | درایور آدیت؛ **در روت workspace است، نه در `clinicpro/`** |
| `.claude/skills/qa-clinicpro/driver.mjs` | ساخت/بررسی اکانت نقش‌ها (`roles`) |
| `docs/security/AUDIT-2026-07-19.md` | یافته‌های قبلی برای regression |
| `docs/security-audit.md` | آدیت ۲۰۲۶-۰۶-۰۹ (قدیمی‌تر) |
| `src/Shared/Security/PermissionCatalog.php` | منبع واحد منابع/اکشن‌های مجوزدهی |
| `src/Shared/Controller/PermissionCatalogController.php` | `GET /api/v1/permission-catalog` |
| `src/Secretary/Security/SecretaryPermissionChecker.php` | enforce مجوز منشی |
| `src/Secretary/Security/SecretaryAccessChecker.php` | حل tenant منشی |
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | enforce مجوز پزشکِ مهمان |
| `src/Clinic/Security/ClinicDoctorAccessChecker.php` | حل tenant پزشکِ مهمان |
| `src/Staff/Security/StaffRouteGuardSubscriber.php` | allowlist مسیرهای پرسنل |
| `src/Staff/Security/StaffPermissions.php` | مجوزهای پرسنل |
| `src/Patient/Security/PatientRecordScopeResolver.php` | محدودسازی دید پروندهٔ بیمار |
| `src/Treatment/Controller/TreatmentCaseController.php` | پروندهٔ درمان |
| `src/Treatment/Controller/TreatmentProtocolController.php` | پروتکل سرویس |
| `src/Treatment/Controller/SessionExecutionController.php` | اجرای جلسه توسط پرسنل |
| `src/Resource/Controller/ResourcePermissionTrait.php` | گیت منابع/دستگاه‌ها |
| `src/Shared/Tenant/TenantFilter.php` | فیلتر Doctrine جداسازی محیط |
| `src/Shared/Tenant/TenantOwnershipChecker.php` | بررسی مالکیت محیط |
| `src/Shared/Tenant/GlobalTables.php` | entityهای عمداً غیر-tenant |
| `config/packages/security.yaml` | firewall و مسیرهای public |
| `assets/admin/` | پنل ادمین؛ مجوزهای UI |
| `docs/architecture/tenancy.md` | «چه تضمین می‌دهد و چه نمی‌دهد» |
## وضعیت فعلی
### ۱. عدم تقارن گیت در دامنهٔ Treatment — قوی‌ترین lead
`TreatmentCaseController` کلاسش فقط با احراز هویت گارد شده و مجوز را متد‌به‌متد می‌سنجد:
```php
// src/Treatment/Controller/TreatmentCaseController.php:31
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TreatmentCaseController extends BaseController
{
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_show', methods: ['GET'])]
public function show(#[CurrentUser] User $user, string $uuid): JsonResponse
{
$this->denyUnlessGranted($user, 'view');
$case = $this->requireCase($user, $uuid);
// ...
}
#[Route('/api/v1/treatment-case/{uuid}', name: 'treatment_case_update', methods: ['PATCH'])]
public function update(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$this->denyUnlessGranted($user, 'update');
// ...
}
}
```
ولی `TreatmentProtocolController` **هیچ** `denyUnlessGranted` ندارد — نه روی خواندن، نه روی
`PUT`، نه روی `DELETE`:
```php
// src/Treatment/Controller/TreatmentProtocolController.php:24
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class TreatmentProtocolController extends BaseController
{
#[Route('/api/v1/service-item/{uuid}/treatment-protocol', methods: ['PUT'])]
public function replace(#[CurrentUser] User $user, string $uuid, Request $request): JsonResponse
{
$data = json_decode($request->getContent(), true);
if (!is_array($data)) {
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'بدنهٔ درخواست نامعتبر است', 422);
}
$protocol = $this->writer->replace($user, $this->requireItem($user, $uuid), $data);
return $this->success($protocol->toArray());
}
}
```
یعنی تنها دفاع، `requireItem($user, $uuid)` است. اگر آن فقط مالکیت tenant را بسنجد و نه مجوز
اکشن، هر کاربرِ داخل همان کلینیک — از جمله منشی‌ای که مجوز `services` ندارد — می‌تواند پروتکل
درمان یک سرویس را بازنویسی یا حذف کند. **باید بازتولید شود، نه فرض.**
### ۲. گاردِ پرسنل مبتنی بر allowlist مسیر
```php
// src/Staff/Security/StaffRouteGuardSubscriber.php
private const ALLOWED_PREFIXES = [
'/api/v1/dashboard/staff',
'/api/v1/auth/switch-context',
'/api/v1/user/change-password',
];
private const OVERRIDING_ROLES = [
'ROLE_ADMIN', 'ROLE_CLINIC', 'ROLE_DOCTOR', 'ROLE_SECRETARY', 'ROLE_REPRESENTATION',
];
public function onKernelRequest(RequestEvent $event): void
{
// ...
$path = $event->getRequest()->getPathInfo();
if (!str_starts_with($path, '/api/')) {
return;
}
// ...
foreach (self::ALLOWED_PREFIXES as $prefix) {
if (str_starts_with($path, $prefix)) {
return;
}
}
throw new AppException(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی پرسنل به این بخش مجاز نیست', 403);
}
```
دو ریسک ساختاری که باید تست شوند:
- گارد روی `getPathInfo()` و `str_starts_with` کار می‌کند. مسیر نرمال‌نشده
(`/api/v1/../v1/patients`، دابل‌اسلش، درصد-انکود) ممکن است هم از شرط `/api/` رد شود هم از
allowlist بیفتد یا برعکس، از روتر عبور کند ولی از گارد نه.
- `OVERRIDING_ROLES` گارد را برای کاربر چندنقشی **کاملاً** کنار می‌گذارد. اگر کاربری هم پرسنل
و هم منشی باشد و context فعالش پرسنل باشد، این گارد اجرا نمی‌شود.
### ۳. یافته‌های باز از ۲۰۲۶-۰۷-۱۹
| # | یافته | شدت | وضعیت ثبت‌شده |
|---|-------|-----|----------------|
| 2 | `lodash` code injection via `_.template` | 🟧 High | نیمه‌رفع |
| 2b | ۶۱ moderate در CKEditor build-classic (deprecated) | 🟨 Medium | باز — خارج از محدودهٔ این پرامپت |
| 5 | پسورد sandbox درگاه ملت هاردکد در `MellatGateway.php:23` | ⬜ Info | باز |
### ۴. وضعیت اکانت‌های تست
`TEST_USERS.md` بی‌اعتبار است و `create_test_users.php` که به آن ارجاع می‌دهد در repo نیست.
**وضعیت واقعی DB لوکال، سنجیده‌شده در ۲۰۲۶-۰۸-۰۷** — نه از حافظه، خروجی
`driver.mjs roles` و `ddev mysql`:
```
admin 09120671756 ROLE_USER,ROLE_ADMIN ✓ login موفق
representation 09124000001 ROLE_USER,ROLE_REPRESENTATION ✓ login موفق
doctor 09390039833 ROLE_USER,ROLE_CLINIC ⚠ نقشش عوض شده — دیگر DOCTOR نیست
clinic 09127000000 — ✗ کاربر در DB نیست
secretary 09123456778 — ✗ کاربر در DB نیست
```
DB از زمان آدیت قبلی دوباره seed شده. کاربران قابل استفاده که واقعاً وجود دارند:
```
id=47 09128726723 ROLE_USER,ROLE_STAFF ← کاربرِ «فقط پرسنل»، موجود است
id=11 0912000201 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC ← کاربر چندنقشی، موجود است
id=24 0912000301 ROLE_USER,ROLE_CLINIC
id=5 0912000109 ROLE_USER,ROLE_SECRETARY
id=15 0912000209 ROLE_USER,ROLE_SECRETARY
id=28 0912000309 ROLE_USER,ROLE_SECRETARY
```
پس کاربر پرسنل و کاربر چندنقشی **ساخته نمی‌شوند** — فقط پسوردشان باید معلوم/ست شود.
پسورد سری `0912000xxx` نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش.
---
## وظایف
### ۱. آماده‌سازی ماتریس نقش‌ها
اول وضعیت واقعی اکانت‌ها را بسنج، بعد کمبود را بساز:
```bash
ddev start
node .claude/skills/qa-clinicpro/driver.mjs roles
```
کاربر «فقط پرسنل» (`09128726723`) و کاربر چندنقشی (`0912000201`) از قبل در DB هستند — ساخته
نمی‌شوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن:
```bash
ddev exec php bin/console security:hash-password 'QaTest@1234'
ddev mysql -e "UPDATE users SET password_hash='<hash>' WHERE mobile_number='09128726723';"
```
این تغییر فقط روی DB لوکال است و در گزارش ثبت می‌شود.
علاوه بر آن، برای تست IDOR لازم است:
- **tenant دوم** (کلینیک B) با حداقل یک پروندهٔ درمان، یک سرویس با پروتکل، و یک بیمار.
اول بگرد ببین از قبل هست؛ فقط اگر نبود بساز.
خروجی این وظیفه یک جدول creds در ابتدای گزارش است.
**نحوه تست:** برای هر کاربر ساخته‌شده، `POST /api/v1/auth/login` باید 200 و توکن بدهد؛ در
گزارش، `roles` هر توکن decode‌شده ثبت شود.
---
### ۲. اجرای درایور به‌عنوان baseline
**با درایور شروع کن، نه با grep دستی.** اسکیل `symfony-security-audit` را طبق
`.claude/skills/symfony-security-audit/SKILL.md` اجرا کن (white-box: deps/sinks/guards/secrets/config —
black-box: authz/headers/cors/injection).
خروجی خام درایور **یافته نیست، lead است**. هر lead باید با خواندن کد تأیید یا رد شود، و
leadهای ردشده با دلیل در بخش «رد شد» گزارش بیایند — دقیقاً همان قانونی که آدیت ۲۰۲۶-۰۷-۱۹
رعایت کرده بود.
**نحوه تست:** خروجی درایور با تعداد lead به تفکیک شدت در گزارش ثبت شود.
---
### ۳. regression یافته‌های آدیت قبلی
برای هر یافتهٔ `docs/security/AUDIT-2026-07-19.md` که «رفع شد» علامت خورده، بازتولیدِ همان
گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است:
```bash
# CSP روی SPA ادمین
curl -sI https://clinic-pro.ddev.site/admin | grep -i content-security-policy
# APP_SECRET در فایل env تحت git
git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET='
# فلگ‌های session cookie
ddev exec php bin/console debug:config framework session
# وابستگی‌های npm
npm audit --json | python3 -c "import json,sys; m=json.load(sys.stdin)['metadata']['vulnerabilities']; print(m)"
```
هر کدام برگشته بود، **رگرسیون** است و شدتش یک درجه بالاتر ثبت می‌شود — چون قبلاً رفع شده بوده
و دوباره شکسته.
**نحوه تست:** جدول «یافتهٔ قبلی / وضعیت امروز / خروجی بازتولید» در گزارش.
---
### ۴. ماتریس enforcement مجوزها — هستهٔ این آدیت
`PermissionCatalog::RESOURCES` منبع واحد است. برای **هر** جفت `(resource, action)` در آن، این
سه سؤال جواب داده شود:
1. کدام اندپوینت(ها) این مجوز را نمایندگی می‌کنند؟
2. آیا backend واقعاً enforce می‌کند، یا فقط UI دکمه را پنهان می‌کند؟
3. آیا منشی و پزشکِ مهمان **هر دو** enforce می‌شوند، یا فقط یکی؟
روش: با اکانت منشی، مجوز X را در DB خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن.
```bash
TOKEN=$(curl -s -X POST https://clinic-pro.ddev.site/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"mobile":"09123456778","password":"QaTest@1234"}' | python3 -c 'import json,sys; print(json.load(sys.stdin)["data"]["token"])')
# مجوز treatment.update خاموش است → باید 403 بدهد
curl -s -o /dev/null -w '%{http_code}\n' -X PATCH \
https://clinic-pro.ddev.site/api/v1/treatment-case/<uuid> \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"status":"closed"}'
```
هر جفتی که **200** بدهد یک یافتهٔ 🟧 High است (bypass مجوز)، مگر اینکه با خواندن کد ثابت شود
عمداً باز است — که آن‌وقت باید در `docs/api/` مستند باشد.
خروجی: جدول کامل `resource × action × role × HTTP status` در گزارش.
**نحوه تست:** خودِ جدول تست است. هر ردیف باید دستور و کد وضعیت واقعی داشته باشد.
---
### ۵. آدیت دامنهٔ Treatment — IDOR و عدم تقارن گیت
سه کنترلر `src/Treatment/Controller/*` کامل خوانده شوند. مشخصاً:
الف) **`TreatmentProtocolController` بدون `denyUnlessGranted`** (بخش «وضعیت فعلی ۱»). بررسی کن
`requireItem()` دقیقاً چه می‌سنجد — فقط tenant، یا مجوز اکشن هم؟
```bash
# با توکن منشیِ بدون مجوز services
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE \
https://clinic-pro.ddev.site/api/v1/service-item/<uuid>/treatment-protocol \
-H "Authorization: Bearer $SECRETARY_TOKEN"
# انتظار: 403 — اگر 200/204 داد، یافتهٔ High
```
ب) **IDOR بین tenant.** با توکن کلینیک A، uuid منابع کلینیک B را صدا بزن. برای هر مسیر:
```
GET /api/v1/treatment-case/{uuid}
PATCH /api/v1/treatment-case/{uuid}
GET /api/v1/treatment-case/{uuid}/plan
GET /api/v1/treatment-session/{uuid}
GET /api/v1/treatment-session/{uuid}/slot-suggestions
GET /api/v1/service-item/{uuid}/treatment-protocol
PUT /api/v1/service-item/{uuid}/treatment-protocol
DELETE /api/v1/service-item/{uuid}/treatment-protocol
POST /api/v1/dashboard/staff/treatment-session/{uuid}/start
POST /api/v1/dashboard/staff/treatment-session/{uuid}/finish
POST /api/v1/dashboard/staff/session-area/{uuid}/start
POST /api/v1/dashboard/staff/session-area/{uuid}/complete
POST /api/v1/dashboard/staff/session-area/{uuid}/skip
POST /api/v1/dashboard/staff/session-area/{uuid}/reopen
```
انتظار: 403 یا 404. هر 200 یا 500 یافته است.
ج) **پرسنلِ کلینیک A روی جلسهٔ کلینیک B.** `SessionExecutionController` با `ROLE_STAFF` گارد
شده، ولی نقش ≠ مالکیت. تست کن که `start`/`finish` روی جلسهٔ tenant دیگر رد می‌شود.
د) **دستکاری وضعیت.** `finish` روی جلسه‌ای که `start` نشده، `reopen` روی ناحیهٔ جلسهٔ بسته،
`complete` دو بار پشت سر هم. اگر منجر به وضعیت ناسازگار یا 500 شود، ثبت شود.
**نحوه تست:** هر خط بالا با `curl` و کد وضعیت واقعی. برای هر یافتهٔ تأییدشده، یک تست PHPUnit
در `tests/` که آن مسیر را با کاربر بیگانه می‌زند و 403 انتظار دارد.
---
### ۶. آدیت فرار از tenant
`docs/architecture/tenancy.md` صریح می‌گوید `TenantFilter` یک تور ایمنی است، نه authorization،
و روی سه چیز اعمال **نمی‌شود**: SQL خام، `getReference()`، و فرزندان aggregate.
```bash
# SQL خام
grep -rnE "createNativeQuery|->getConnection\(\)|executeQuery\(|executeStatement\(" src/ --include=*.php
# getReference
grep -rn "getReference(" src/ --include=*.php
# entityهای بدون طبقه‌بندی tenant
ddev exec php bin/phpunit --filter TenantSchemaCoverageTest
```
هر hit را بخوان و جواب بده: ورودی کاربر مستقیم وارد کوئری می‌شود؟ tenant دستی چک شده؟
**نحوه تست:** `TenantSchemaCoverageTest` سبز باشد. برای هر SQL خامی که ورودی کاربر می‌گیرد،
یک تست تزریق با payload واقعی (`' OR '1'='1`, `1; DROP`) و تأیید اینکه پارامتری‌سازی شده.
---
### ۷. آدیت گاردِ پرسنل
الف) **نرمال‌سازی مسیر** (بخش «وضعیت فعلی ۲»):
```bash
for P in \
'/api/v1/patients' \
'/api//v1/patients' \
'/api/v1/dashboard/staff/../../patients' \
'/api/v1/%2e%2e/v1/patients' \
'/API/v1/patients' ; do
printf '%s -> ' "$P"
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is \
"https://clinic-pro.ddev.site$P" -H "Authorization: Bearer $STAFF_TOKEN"
done
```
هر چیزی جز 403/404 روی این مسیرها یافتهٔ 🟧 High است.
ب) **کشف مسیرهای تازه‌ای که گارد نمی‌بیند.** فهرست کامل روت‌ها را بگیر و همه را با توکن پرسنل
بزن:
```bash
ddev exec php bin/console debug:router --format=json > /tmp/routes.json
```
اندازهٔ واقعی (سنجیده‌شده در ۲۰۲۶-۰۸-۰۷): **۴۹۳** روت زیر `/api/`، از این تعداد **۲۲۲** روت
`GET` و **۱۲۸** روت `GET` بدون path parameter.
**سقف این وظیفه همان ۱۲۸ روتِ بدون پارامتر است** — چون روت پارامتردار به uuid معتبر نیاز دارد و
404 آن با 403 قابل تفکیک نیست. روت‌های پارامتردار در وظیفهٔ ۵ (IDOR) با uuid واقعی پوشش داده
می‌شوند. اگر به هر دلیل کمتر از ۱۲۸ روت زده شد، تعداد و دلیلش در گزارش بیاید — سکوت ممنوع.
برای هر روت درخواست بزن و کد وضعیت را ثبت کن. هر 200 خارج از allowlist یافته است.
ج) **کاربر چندنقشی.** با کاربر `staff + secretary`: بعد از `switch-context` به پرسنل، آیا هنوز
به مسیرهای منشی دسترسی دارد؟ اگر بله، تصمیم بگیر این طراحی است یا نشت — و در گزارش با دلیل
بنویس. اگر نشت است، گارد باید به **context فعال** نگاه کند نه صرفاً به مجموعهٔ نقش‌ها.
**نحوه تست:** جدول `مسیر → کد وضعیت` برای هر سه بخش.
---
### ۸. آدیت پنل ادمین
مجوزهای UI نباید تنها لایهٔ دفاع باشند. برای هر جایی که `assets/admin/` بر اساس مجوز چیزی را
پنهان می‌کند، تأیید کن endpoint متناظر هم بسته است — نتیجهٔ وظیفهٔ ۴ همین را می‌دهد؛ اینجا فقط
نگاشت UI به endpoint ثبت شود.
علاوه بر آن:
- توکن JWT در `localStorage['clinicpro-auth']` است. تأیید کن هیچ مسیر جدیدی HTML کاربرساخته را
بدون sanitize رندر نمی‌کند (`dangerouslySetInnerHTML`).
- CSP روی `/admin` هنوز فعال است (وظیفهٔ ۳) و صفحات جدید (treatment، staff، permissions) خطای
CSP در کنسول نمی‌دهند.
```bash
grep -rn "dangerouslySetInnerHTML" assets/admin/
```
**نحوه تست:** لود هر صفحهٔ جدید پنل و ثبت خطاهای کنسول؛ اگر درایور `qa-clinicpro` این را
می‌دهد، از همان استفاده کن.
---
### ۹. رفع
طبق سیاست تعیین‌شده:
- **Critical/High:** همان جلسه رفع + تست رگرسیون در `tests/`. هر رفع کوچک و جدا باشد، نه یک دیف
بزرگ.
- **Medium و پایین‌تر:** فهرست پیشنهاد با دیف پیشنهادی به کاربر نشان بده و **منتظر تأیید بمان**.
قواعد پروژه هنگام رفع:
- گیت جدید در همان لایه‌ای که بقیه هستند — `denyUnlessGranted` در کنترلر یا checker موجود، نه
یک مکانیزم موازی جدید.
- منطق در Service، کوئری در Repository، کنترلر نازک. وابستگی با constructor injection.
- خطا با `AppException(ErrorCodes::ERR_XXX, null, $status)` و پیام فارسی از
`src/Shared/Constant/ErrorCodes.php`. کد جدید لازم شد، همان‌جا اضافه شود.
- اگر Entity عوض شد: `doctrine:migrations:diff` سپس `migrate`.
**نحوه تست:**
```bash
ddev exec php bin/phpunit
ddev exec php vendor/bin/phpstan analyse
```
---
### ۱۰. گزارش
فایل `docs/security/AUDIT-2026-08-07.md` با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹:
1. خلاصهٔ وضعیت — جدول `# / یافته / شدت / وضعیت`
2. جدول creds نقش‌ها که آدیت با آن اجرا شد
3. برای هر یافته: فایل و خط، ریسک، شرح، **بازتولید با خروجی واقعی**، رفع اعمال‌شده، علت انتخاب
راه‌حل
4. جدول کامل ماتریس مجوز (وظیفهٔ ۴)
5. «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند
6. «leadهایی که رد شدند» با دلیل
7. «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا
8. «risk پذیرفته‌شده» — مهاجرت CKEditor، با ارجاع به تصمیم امروز
---
## نکات مهم
- **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نمی‌شود. خروجی درایور lead است، نه
یافته. این قانون از گزارش قبلی می‌آید و باید حفظ شود.
- **دلتا یعنی دلتا.** آدیت کامل OWASP از صفر تکرار نشود. اگر یک ناحیه در ۲۰۲۶-۰۷-۱۹ سبز بود و
کدش عوض نشده، فقط اشاره شود که regression شد؛ عمیق نشو.
- **نقش ≠ مالکیت.** `#[IsGranted('ROLE_STAFF')]` فقط می‌گوید کاربر پرسنل است، نه اینکه این جلسه
مال اوست. هر جا فقط نقش چک شده و مالکیت نه، یک lead است.
- **`TenantFilter` را authorization فرض نکن.** روی SQL خام، `getReference()` و فرزندان aggregate
اعمال نمی‌شود. `docs/architecture/tenancy.md` مرجع است.
- **پیام خطا نشت ندهد.** روی منبع tenant دیگر، تفاوت پیام «یافت نشد» و «دسترسی ندارید» خودش
یک enumeration oracle است. رفتار فعلی را ثبت کن؛ اگر ناسازگار بود، یکدست کن.
- **رفع نباید رفتار مجاز را بشکند.** قبل از هر گیت جدید، مسیر مجازِ همان اندپوینت با نقش درست
تست شود که هنوز 200 می‌دهد.
- **`docs/api/`** — هر تغییر در قرارداد، وضعیت یا مجوز یک endpoint، همان جلسه در فایل متناظر
ثبت شود. این قانون ایستادهٔ پروژه است.
- **`nobat724_front`** کلاینت همین API است. اگر گیتی روی endpointی اضافه شد که سایت عمومی صرف
می‌کند، در گزارش هشدار بده — build کلاینت خطا نمی‌دهد.
- **بدون DoS.** آدیت روی محیط لوکال ddev اجرا می‌شود. تست rate limit با چند درخواست شمارشی، نه
با سیل ترافیک.
- **secret واقعی در گزارش ننویس.** مقدار را ماسک کن و فقط فایل و خط را بده.