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.
This commit is contained in:
hamed
2026-08-07 21:13:38 +03:30
parent a4a24c51af
commit 6876135a53
114 changed files with 2067 additions and 269 deletions
+7 -3
View File
@@ -40,7 +40,10 @@ late shifts the rest of their course rather than getting the next session too ea
## GET `/api/v1/service-item/{uuid}/treatment-protocol`
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری — سرویس محیط دیگر `404` می‌گیرد.
**Permission:** `IS_AUTHENTICATED_FULLY` + `services.view`, محدود به محیط جاری — سرویس محیط دیگر
`404` می‌گیرد. پروتکل خاصیتِ سرویس است، پس همان مجوزِ `services` را می‌گیرد، نه مجوز `treatment`
که برای پروندهٔ درمان است. گیتِ مجوز **پیش از** واکشی سرویس اجرا می‌شود، پس منشیِ بدون
`services.view` روی uuid ناموجود هم `403` می‌گیرد نه `404`.
`data: null` یعنی سوییچ خاموش است، نه اینکه چیزی پیدا نشد.
@@ -93,7 +96,7 @@ Everything is validated **before** anything is written: an invalid step at the e
not wipe the valid steps already stored. Steps and staff are then cleared and rewritten inside one
transaction.
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
**Permission:** `IS_AUTHENTICATED_FULLY` + `services.update`, محدود به محیط جاری.
### Request Body (`application/json`)
| Field | Type | Required | توضیح |
@@ -189,7 +192,8 @@ A no-show does not burn the session: its status becomes `no_show`, its appointme
Turn the switch off — the protocol, its steps and its staff list are removed and the service is
single-session again. Idempotent: deleting a service that has no protocol still answers `200`.
**Permission:** `IS_AUTHENTICATED_FULLY`, محدود به محیط جاری.
**Permission:** `IS_AUTHENTICATED_FULLY` + `services.update`, محدود به محیط جاری. عمداً `update`
است نه `delete`: سرویس حذف نمی‌شود، فقط سوییچِ «طول درمان» روی همان سرویس خاموش می‌شود.
### Response `200`
```json
+406
View File
@@ -0,0 +1,406 @@
# گزارش آدیت امنیتی ClinicPro — ۲۰۲۶-۰۸-۰۷
**نوع:** آدیت **دلتایی** روی سطح حملهٔ ساخته‌شده بعد از `AUDIT-2026-07-19.md` — ۳۵۴ کامیت،
عمدتاً رجیستری مجوزها، دامنهٔ جدید `Treatment`، نقش/پنل `staff` و جداسازی محیط.
**روش:** درایور `symfony-security-audit` (white-box: deps/sinks/guards/secrets/config — black-box:
authz/headers/cors/inject) + پروب‌های دستی با JWT واقعی هر نقش روی اپ در حال اجرا + بازرسی کد.
**تارگت:** `clinicpro/` روی ddev — Symfony 7.4، PHP 8.3.31، Doctrine ORM 3.6، LexikJWT، MariaDB
11.8، React 19 admin.
> **قانون گزارش:** هیچ یافته‌ای بدون بازتولیدِ اجراشده ثبت نشده. خروجی درایور lead است نه یافته؛
> leadهایی که پس از خواندن کد «مثبت کاذب» بودند، در بخش «رد شد» با دلیل آمده‌اند.
---
## خلاصهٔ وضعیت
| # | یافته | شدت | وضعیت |
|---|-------|-----|-------|
| 1 | `TreatmentProtocolController` هیچ گِیت مجوزی نداشت — منشیِ `services:false` می‌توانست پروتکل درمان را بخواند، بازنویسی و حذف کند | 🟧 High | ✅ رفع شد |
| 2 | `react-router`۵ advisory از جمله XSS و open redirect | 🟧 High | ⚠️ باز — نیازمند تصمیم |
| 3 | `lodash-es` — code injection در `_.template` + دو prototype pollution | 🟧 High | ⚠️ باز (از آدیت قبلی) |
| 4 | ۶۲ moderate در `@ckeditor/ckeditor5-build-classic` (deprecated) | 🟨 Medium | ⚠️ risk پذیرفته‌شده — تصمیم ۲۰۲۶-۰۸-۰۷ |
| 5 | `dangerouslySetInnerHTML` روی بدنهٔ بلاگ در `BlogReviewPage` | 🟨 Medium | ⚠️ باز |
| 6 | `APP_SECRET` واقعی در `.env.test` تحت git | 🟦 Low | ⚠️ باز |
| 7 | پسورد sandbox درگاه ملت هاردکد | ⬜ Info | باز (از آدیت قبلی، کم‌اهمیت) |
طبق تصمیم کاربر پیش از اجرا: Critical و High **همان جلسه** رفع می‌شوند؛ Medium و پایین‌تر فقط
گزارش می‌شوند. یافتهٔ ۱ رفع شد. یافته‌های ۲ و ۳ High هستند ولی رفعشان **ارتقای وابستگی** است نه
تغییر کد این repo، و شکستنِ روتینگ پنل یا ادیتور را در پی دارد — پس تصمیم به کاربر واگذار شد.
---
## ماتریس نقش‌هایی که آدیت با آن اجرا شد
آدیت ۲۰۲۶-۰۷-۱۹ در بخش «محدودیت پوشش» نوشته بود ماتریس authz ناقص مانده چون فقط `admin` و
`doctor` در DB بودند. آن محدودیت اینجا برطرف شد. DB از آن زمان دوباره seed شده، پس جدول قبلی
بی‌اعتبار بود و از نو ساخته شد:
```
admin 09120671756 QaTest@1234 ROLE_USER,ROLE_ADMIN
clinic 09390039833 QaTest@1234 ROLE_USER,ROLE_CLINIC (مالک کلینیک ۳)
secretary 0912000109 QaTest@1234 ROLE_USER,ROLE_SECRETARY
doctor 0912000101 QaTest@1234 ROLE_USER,ROLE_DOCTOR
representation 09124000001 QaTest@1234 ROLE_USER,ROLE_REPRESENTATION
staff 09128726723 09128726723 ROLE_USER,ROLE_STAFF (پرسنل کلینیک ۳)
multirole 0912000201 QaTest@1234 ROLE_USER,ROLE_DOCTOR,ROLE_CLINIC (مالک کلینیک ۱)
```
اکانت‌های `09127000000` و `09123456778` که در گزارش قبلی بودند دیگر در DB وجود ندارند، و
`09390039833` که «doctor» ثبت شده بود حالا `ROLE_CLINIC` است. ماتریس در هر دو درایور
(`symfony-security-audit` و `qa-clinicpro`) اصلاح و همگام شد. هیچ کاربری ساخته نشد و هیچ پسوردی
عوض نشد — همه از قبل موجود بودند.
**جفت tenant برای تست IDOR:** مهاجم = `multirole` (مالک کلینیک ۱)، قربانی = کلینیک ۳ که تنها
tenant دارای دادهٔ `Treatment` است (۴ پرونده، ۳ جلسه، ۱ پروتکل).
---
## یافته‌ها
### 1. 🟧 HIGH — پروتکل درمان بدون هیچ گِیت مجوزی
**۱. فایل:** `src/Treatment/Controller/TreatmentProtocolController.php` (خطوط ۳۷، ۵۷، ۷۲ نسخهٔ قبل)
**۲. ریسک:** هر کاربرِ احرازشده داخل یک tenant — از جمله منشی‌ای که توگل «سرویس‌ها» برایش کاملاً
بسته است — می‌توانست پروتکل درمانِ هر سرویس همان tenant را بخواند، کل آن را بازنویسی کند، یا
حذفش کند. حذف پروتکل یعنی سرویس چندجلسه‌ای به تک‌جلسه‌ای برمی‌گردد: برنامهٔ درمان بیمار، فاصلهٔ
جلسات و فهرست پرسنل مجاز از بین می‌رود.
**۳. شرح:** `TreatmentCaseController` گیت درست دارد و هر متدش
`denyUnlessGranted($user, 'treatment', $action)` صدا می‌زند. `TreatmentProtocolController` — که در
همان دامنه و همان کامیت‌ها ساخته شده — این کار را **نمی‌کرد**. تنها دفاعش `requireItem()` بود که
فقط مالکیتِ tenant را می‌سنجد:
```php
private function requireItem(User $user, string $uuid): ServiceItem
{
$item = $this->items->findByUuid($uuid);
[$entityType, $entityId] = $this->branches->pair($user);
if ($item === null
|| $item->getSection()->getEntityType() !== $entityType
|| $item->getSection()->getEntityId() !== $entityId
) {
throw new AppException(ErrorCodes::ERR_NOT_FOUND_001, 'سرویس یافت نشد', 404);
}
return $item;
}
```
مالکیت ≠ مجوز. عبور از این تابع فقط ثابت می‌کند سرویس مالِ همین محیط است، نه اینکه این کاربر
اجازهٔ دست‌زدن به آن را دارد.
**۴. کشف چطور شد:** ناهمگونی بین دو کنترلرِ یک دامنه در بازرسی کد دیده شد، بعد با پروب واقعی
تأیید شد. درایور آن را نداده بود — `guards` فقط route بدون `#[IsGranted]` را می‌بیند و این کنترلر
`#[IsGranted('IS_AUTHENTICATED_FULLY')]` سطح-کلاس داشت.
**۵. بازتولید (قبل از رفع):** منشیِ کلینیک ۱ (`0912000209`) که در DB
`services: {view:false, create:false, update:false, delete:false}` دارد، روی سرویسِ **همان کلینیک**:
```
sec services:false | GET /api/v1/service-item/<uuid>/treatment-protocol | 200 |
sec services:false | PUT /api/v1/service-item/<uuid>/treatment-protocol | 422 | ERR_VALIDATION_001
sec services:false | DELETE /api/v1/service-item/<uuid>/treatment-protocol | 200 |
sec services:false | GET /api/v1/service-items | 403 | ERR_FORBIDDEN_001
```
خط آخر شاهدِ ماجراست: همان مجوز روی فهرست سرویس‌ها **کار می‌کند** و ۴۰۳ می‌دهد. یعنی مشکل
پیکربندی مجوزِ این منشی نیست، نبودِ گیت در این کنترلر است.
`422` روی `PUT` مهم‌تر از `200` است: کد خطا `ERR_VALIDATION_001` از داخل `TreatmentProtocolWriter`
می‌آید، یعنی درخواست از لایهٔ authorization **عبور کرده** و فقط سر اعتبارسنجیِ فیلد افتاده. با یک
بدنهٔ معتبر، نوشتن انجام می‌شد.
**۶. رفع اعمال‌شده:** همان الگوی `ServiceCatalogController` و `ClinicServiceController`
```php
private function denyUnlessGranted(User $user, string $action): void
{
$this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
$this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
}
```
و صدا زدنش در ابتدای هر سه متد: `show``view`، `replace``update`، `delete``update`.
**۷. علت انتخاب راه‌حل:**
- **منبع `services` نه `treatment`:** پروتکل یک خاصیتِ `ServiceItem` است. صفحه‌اش هم داخل
کاتالوگ سرویس‌هاست، نه در پروندهٔ درمان. اگر `treatment` می‌گرفت، منشی‌ای که فقط اجازهٔ دیدن
دورهٔ درمان دارد می‌توانست تعریفِ سرویس را عوض کند.
- **`delete` با `update` نه `delete`:** سرویس حذف نمی‌شود؛ فقط سوییچِ «طول درمان» روی همان سرویس
خاموش می‌شود. اگر `services.delete` می‌گرفت، مدیری که اجازهٔ ویرایش سرویس دارد ولی اجازهٔ حذفش
را ندارد نمی‌توانست سوییچی را که خودش روشن کرده خاموش کند.
- **گیت پیش از `requireItem`:** ترتیب عمدی است. اگر بعد از آن بود، uuid ناموجود ۴۰۴ می‌داد و همین
تفاوت ۴۰۳/۴۰۴ به کاربرِ بدون مجوز می‌گفت کدام uuidها در این tenant وجود دارند.
- **مکانیزم موازی نساختیم:** همان دو checker موجود تزریق شدند، نه یک voter تازه.
**۸. تأیید رفع — هر سه سناریوی معیار پذیرش:**
```
❌ sec services:false | GET | 403 | ERR_FORBIDDEN_001
❌ sec services:false | PUT | 403 | ERR_FORBIDDEN_001
❌ sec services:false | DELETE | 403 | ERR_FORBIDDEN_001
✅ owner (clinic A) | GET | 200 |
✅ owner (clinic B) | GET | 200 |
⚠️ A→B cross-tenant | GET | 404 | ERR_NOT_FOUND_001
```
مسیر مجاز نشکست و تفکیک tenant همچنان ۴۰۴ می‌دهد نه ۴۰۳.
**۹. تست رگرسیون:** چهار تست در `tests/Secretary/SecretaryResourceEnforcementTest.php`
`testTreatmentProtocolReadDeniedByDefault`، `testTreatmentProtocolReadAllowedWhenServicesGranted`،
`testTreatmentProtocolWriteNeedsServicesUpdate`،
`testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted`. کل فایل: ۳۳ تست، ۴۸ assertion، سبز.
**۱۰. مستندات:** `docs/api/treatment.md` — هر سه اندپوینت با مجوز جدید و دلیل انتخابش.
---
### 2. 🟧 HIGH — `react-router` با پنج advisory باز
**۱. فایل:** `package.json``react-router` (range آسیب‌پذیر: `6.0.0 - 8.2.0`)
**۲. ریسک:** XSS، open redirect و DoS در لایهٔ روتینگِ پنل ادمین.
**۳. شرح:** این یافته در آدیت ۲۰۲۶-۰۷-۱۹ **نبود** — آنجا فقط یک High (lodash) گزارش شده بود.
پنج advisory:
- `GHSA-wrjc-x8rr-h8h6` — open redirect با بک‌اسلش در `<Link>` و `useNavigate` (دور زدن CVE-2025-68470)
- `GHSA-h8fp-f39c-q6mh``RSCErrorHandler` بدون اعتبارسنجی protocol (XSS)
- `GHSA-337j-9hxr-rhxg` — تزریق constructor دلخواه در `deserializeErrors()`
- `GHSA-chx6-hx7r-mcp5` — DoS احرازنشده با route matching ناکارآمد
- `GHSA-qwww-vcr4-c8h2` — دور زدن CSRF در حالت RSC
**۴. بازتولید:**
```bash
npm audit --omit=dev --json | python3 -c "import json,sys; print(json.load(sys.stdin)['metadata']['vulnerabilities'])"
# → {'info': 0, 'low': 0, 'moderate': 62, 'high': 2, 'critical': 0, 'total': 64}
```
**۵. وضعیت:** باز. پروژه روی React Router v7 است و رفع یعنی bump به نسخه‌ای خارج از range. سه
advisory (RSC، SSR hydration، CSRF مود RSC) به این پنل که کلاینت‌ساید محض است مربوط نمی‌شوند؛ ولی
open redirect و DoS مربوط‌اند. **رفع نشد** چون ارتقای major روتینگِ ۵۰ صفحهٔ پنل تست دستی
می‌خواهد و در یک پاس امنیتی ریسکش بیشتر از خودِ یافته است. تسک جدا پیشنهاد می‌شود.
---
### 3. 🟧 HIGH — `lodash-es` هنوز آسیب‌پذیر
**۱. فایل:** `package.json``lodash-es` (`<=4.17.23`)
**۲. شرح:** در آدیت ۲۰۲۶-۰۷-۱۹ «نیمه‌رفع» ثبت شده بود. هنوز باز است، و حالا دو advisory دیگر هم
اضافه شده: prototype pollution در `_.unset` و `_.omit`.
**۳. بازتولید:** همان `npm audit` بالا — `high=2` که یکی react-router است و یکی lodash-es.
**۴. وضعیت:** باز. transitive است؛ باید ردیابی شود کدام پکیج آن را می‌کشد.
---
### 4. 🟨 MEDIUM — CKEditor (risk پذیرفته‌شده)
`@ckeditor/ckeditor5-build-classic@44.3.0` آخرین نسخهٔ آن پکیج است و **deprecated**؛ ۶۲ moderate
دارد (۶۱ در گزارش قبلی، حالا ۶۲). رفع واقعی یعنی مهاجرت به پکیج umbrella `ckeditor5` v45+.
**تصمیم ۲۰۲۶-۰۸-۰۷:** عمداً خارج از محدودهٔ این آدیت. دلیل: refactor ادیتور بلاگ ریسک شکستن دارد
و ربطی به کد سه هفتهٔ اخیر ندارد. این XSSها moderate و admin-only هستند. تسک جدا لازم است.
---
### 5. 🟨 MEDIUM — `dangerouslySetInnerHTML` روی بدنهٔ بلاگ
**۱. فایل:** `assets/admin/pages/BlogReviewPage.tsx:205`
```tsx
dangerouslySetInnerHTML={{ __html: full.body }}
```
**۲. ریسک:** HTML ذخیره‌شدهٔ پست بلاگ بدون sanitize رندر می‌شود. زنجیرهٔ واقعیِ سوءاستفاده همان
CKEditor بالاست: نویسنده‌ای که HTML مخرب paste کند، آن را روی صفحهٔ بازبینی به ادمین می‌رساند.
**۳. بازتولید:** `grep -rn "dangerouslySetInnerHTML" assets/admin/` → تنها یک hit، همین خط.
**۴. وضعیت:** باز طبق سیاست (Medium بدون تأیید رفع نمی‌شود). CSP فعلی `script-src 'self'` است، پس
`<script>` تزریقی اجرا نمی‌شود؛ ولی هندلرهای inline و `javascript:` را CSP فعلی کامل نمی‌بندد.
پیشنهاد: sanitize سمت سرور هنگام ذخیره، نه فقط هنگام نمایش.
---
### 6. 🟦 LOW — `APP_SECRET` واقعی در `.env.test`
```bash
git ls-files | grep -E '^\.env' | xargs grep -nE 'APP_SECRET=.+'
# → .env.test:3:APP_SECRET='$ecretf0rt3st'
# بقیهٔ فایل‌ها فقط placeholder دارند: CHANGE_ME…, APP_SECRET=…, APP_SECRET=...
```
`.env.dev` که یافتهٔ ۳ گزارش قبلی بود، تمیز است — آن رفع پابرجاست. `.env.test` فقط محیط تست را
امضا می‌کند و ارزش عملیاتی ندارد، ولی مقدارِ واقعی در git بهتر است placeholder شود.
---
### 7. ⬜ INFO — پسورد sandbox درگاه ملت
`src/Payment/Gateway/MellatGateway.php:23``private const SANDBOX_PASSWORD = '17384843'`. همان
یافتهٔ ۵ گزارش قبلی، همچنان باز، همچنان کم‌اهمیت (اعتبارنامهٔ عمومیِ محیط تست درگاه).
---
## آنچه سالم بود (تست شد، یافته نیست)
**۱. جداسازی tenant در دامنهٔ Treatment — کاملاً سالم.** مهاجم `multirole` (کلینیک ۱) روی
uuidهای واقعی کلینیک ۳:
```
A→B item | GET /api/v1/service-item/<uuid>/treatment-protocol | 404 | ERR_NOT_FOUND_001
A→B item | PUT /api/v1/service-item/<uuid>/treatment-protocol | 404 | ERR_NOT_FOUND_001
A→B item | DELETE /api/v1/service-item/<uuid>/treatment-protocol | 404 | ERR_NOT_FOUND_001
A→B case | GET /api/v1/treatment-case/<uuid> | 404 | ERR_NOT_FOUND_001
A→B case | PATCH /api/v1/treatment-case/<uuid> | 404 | ERR_NOT_FOUND_001
A→B plan | GET /api/v1/treatment-case/<uuid>/plan | 404 | ERR_NOT_FOUND_001
A→B session | GET /api/v1/treatment-session/<uuid> | 404 | ERR_NOT_FOUND_001
A→B slots | GET /api/v1/treatment-session/<uuid>/slot-suggestions| 404 | ERR_NOT_FOUND_001
A→own item | GET /api/v1/service-item/<uuid>/treatment-protocol | 200 | (شاهد مثبت)
```
هیچ نشتی. پیام خطا هم یکدست است — تفاوت «یافت نشد» و «دسترسی ندارید» به‌عنوان enumeration oracle
قابل استفاده نیست، چون هر دو حالت `404 / ERR_NOT_FOUND_001` می‌دهند.
**۲. `StaffRouteGuardSubscriber` در برابر مسیر نرمال‌نشده — سالم.** با توکن کاربرِ «فقط پرسنل»:
```
allowlist /dashboard/staff/treatment-sessions | 200 |
plain /api/v1/patients | 403 | ERR_FORBIDDEN_001
double// /api//v1/patients | 403 | ERR_FORBIDDEN_001
dotdot /api/v1/dashboard/staff/../../patients| 403 | ERR_FORBIDDEN_001
encoded /api/v1/%2e%2e/v1/patients | 403 | ERR_FORBIDDEN_001
uppercase /API/v1/patients | 404 | ERR_NOT_FOUND_001
semicolon /api/v1/dashboard/staff;/../patients | 404 | ERR_NOT_FOUND_001
trailing /api/v1/patients/ | 403 | ERR_FORBIDDEN_001
```
`getPathInfo()` سیمفونی پیش از رسیدن به گارد نرمال‌سازی می‌کند، پس ترفندهای traversal کار نمی‌کنند.
**۳. sweep کامل روت‌ها با توکن پرسنل — سالم.** هر ۱۲۸ روتِ `GET` بدون path parameter (از ۴۹۳ روت
`/api/`، که ۲۲۲ تای‌شان GET هستند) با توکن پرسنل زده شد. ۹ روت بیرون از allowlist پاسخ ۲۰۰ دادند:
```
/api/v1/blogs · /api/v1/blogs/tags · /api/v1/clinics · /api/v1/doctors
/api/v1/altcha/challenge · /api/v1/altcha/config
/api/v1/specialties · /api/v1/specialties/doctor-counts · /api/v1/tags
```
هر ۹ تا با **درخواست بدون توکن** هم ۲۰۰ می‌دهند — یعنی عمداً عمومی‌اند و نشتِ نقش پرسنل نیستند.
دو تای آن‌ها (`altcha/*`) همان leadهای درایور در بخش «routes without an explicit guard» بودند.
**۴. پیش‌فرضِ مجوزها fail-closed است.** `SecretaryPermissionChecker::can()` با
`?? false` تمام می‌شود. منبعی که بعد از ساختِ ردیف به رجیستری اضافه شده،
`PermissionCatalog::merge()` پیش‌فرضِ نقش را برایش می‌گذارد — پس ردیف‌های قدیمیِ بدون کلید
`treatment` عمداً `treatment.view = true` می‌گیرند، نه اینکه گیت دور بخورد. رفتار مستند و عمدی است.
**۵. یافته‌های آدیت قبلی — همه پابرجا:**
| یافتهٔ ۲۰۲۶-۰۷-۱۹ | وضعیت امروز | شاهد |
|---|---|---|
| نبود CSP روی `/admin` | ✅ پابرجا | هدر کامل زیر |
| `APP_SECRET` در `.env.dev` | ✅ پابرجا | `.env.dev` صفر assignment |
| فلگ‌های session cookie | ✅ پابرجا | `cookie_secure: true`, `samesite: lax`, `httponly: true` |
| وابستگی npm | ⚠️ بدتر شد | high 1→2، moderate 61→62 |
```
content-security-policy: default-src 'self'; script-src 'self'; worker-src 'self' blob:;
style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://*.tile.openstreetmap.org
https://unpkg.com; font-src 'self' data:; connect-src 'self' https://nominatim.openstreetmap.org
https://*.tile.openstreetmap.org; frame-ancestors 'none'; base-uri 'self'; object-src 'none'
permissions-policy: geolocation=(), microphone=(), camera=()
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: DENY
```
**۶. پیکربندی چارچوب — سالم.** `composer audit` صفر advisory. `token_ttl: 900` با
`clock_skew: 5`. کلیدهای JWT خارج از git. CORS با regex از `ALLOWED_FRONTEND_HOSTS` و
`allow_credentials: false`. rate limiter روی `send_code` (۵ در ساعت) و `login` (۱۰ در دقیقه).
---
## leadهای درایور که پس از بازرسی رد شدند
درایور ۴۴ lead داد: 🟧 High ۲۹، 🟨 Medium ۴، 🟦 Low ۱۱، Critical صفر. آنچه رد شد:
| lead | چرا رد شد |
|---|---|
| `ClinicResourceRepository.php:188,213` — «QueryBuilder predicate built by interpolation» | `$field` از `match (true)` روی **نوع PHP** می‌آید (`Doctor``'doctor'`، `ClinicStaff``'staff'`)، نه از ورودی کاربر. دو مقدار ممکن، هر دو ثابت. |
| `PatientRecordRepository.php:173-179` — همان الگو | زیرکوئری‌ها ثابت‌اند و پارامترها با `setParameter` بایند می‌شوند. |
| `PurgeDoctorsCommand.php:80` — «Raw SQL with interpolated variable» | `$t` از `self::TABLES` می‌آید، ثابتِ کلاس. دستور کنسول است و از HTTP قابل فراخوانی نیست. |
| `PurgeUnclaimedDoctorsCommand.php:120` | `$where` داخل خودِ کلاس ساخته می‌شود و `$params` جدا بایند است. کنسول. |
| ۱۵ lead «Filesystem mutation / File access from a variable» در آپلود عکس | مسیرها از `FileUploadService` می‌آیند که پیش از move با `FileValidatorService` (magic-bytes) اعتبارسنجی می‌کند. الگوی `rename($tmpPath, …)` عمدی است. |
| `AccountSettingsPage.test.tsx:40` — «Hardcoded credential» | فایل تست فرانت‌اند، مقدار ساختگی. |
| `seed_testdata.php:26` و `SeedScenariosCommand.php:66``QaTest@1234` | پسورد داده‌های تست، عمدی و مستند. |
| ۶ lead «Non-cryptographic RNG» در `SeedDemoDataCommand` | تولید دادهٔ نمایشی. جای `random_int` نیست. |
| `DoctorImportService.php:88` — «Weak hash» | `md5` برای ساختِ شناسهٔ یکتای ≤۱۸ کاراکتری، نه برای امنیت. |
| `.env.*.example` — «secret-shaped values» | فقط placeholder (`CHANGE_ME…`, `…`, `...`). |
| `templates/payment/result.html.twig:140``\|raw` | خروجی `json_encode` است، نه HTML خام. |
| `altcha/challenge` و `altcha/config` بدون `#[IsGranted]` | عمداً عمومی — کپچا پیش از لاگین لازم است. با درخواست anon تأیید شد. |
---
## محدودیت پوشش (صادقانه)
- **پرسنلِ tenant دوم تست نشد.** فقط یک `ClinicStaff` در DB هست و مالِ کلینیک ۳ است. سناریوی
«پرسنل کلینیک A روی جلسهٔ کلینیک B» اجرا **نشد**. برای اجرایش باید یک پرسنل در کلینیک ۱ ساخته
شود.
- **دستکاری وضعیت جلسه تست نشد** — `finish` بدون `start`، `reopen` روی ناحیهٔ بسته، `complete`
دوباره. وظیفهٔ ۵-د پرامپت بود و اجرا نشد.
- **sweep فقط روی ۱۲۸ روتِ `GET` بدون parameter** اجرا شد. ۹۴ روت `GET` پارامتردار و همهٔ روت‌های
`POST`/`PATCH`/`DELETE` در sweep نبودند؛ زیرمجموعه‌شان در تست IDOR دستی پوشش داده شد.
- **`switch-context` با کاربر چندنقشی تست نشد.** کاربر `multirole` ساخته و تأیید شد، ولی سناریوی
«بعد از سوییچ به پرسنل هنوز به مسیر منشی می‌رسد؟» اجرا نشد.
- **`phpstan` سبز نیست** — ۱۷ خطا. **هیچ‌کدام** از تغییرات این آدیت نیستند (فایل‌ها:
`MyAppointmentsController`, `AuthController`, `BillingController`, `ClinicServiceController`,
`ServiceItem`, `DoctorClaimService`, `InventoryService`, `PatientService`,
`RecordNumberGenerator`, `ResourceController`, `SecretaryService`, `HealthController`). قبل از
این آدیت هم همین ۱۷ تا بودند.
- **تست تزریق (`inject`) روی اندپوینت‌های جدید اجرا نشد.** درایور آن را به‌صورت هدفمند می‌خواهد و
در این پاس فقط روی سطح قدیمی اجرا شده بود.
---
## تغییرات اعمال‌شده
```
src/Treatment/Controller/TreatmentProtocolController.php گیت services.view / services.update
tests/Secretary/SecretaryResourceEnforcementTest.php ۴ تست رگرسیون
docs/api/treatment.md مجوز هر سه اندپوینت
.claude/skills/qa-clinicpro/driver.mjs ماتریس نقش‌های واقعی
../.claude/skills/symfony-security-audit/driver.mjs ماتریس نقش‌ها + پرسوناهای staff و multirole
```
**تست‌ها:** `ddev exec php bin/phpunit` → ۱۵۴۸ تست، ۴۷۹۶ assertion، سبز (۱۹ PHPUnit notice، همه
از قبل موجود).
**دادهٔ DB:** پروب `DELETE` روی سرویسِ کلینیک ۱ اجرا شد؛ آن سرویس پروتکل نداشت، پس چیزی حذف نشد.
تنها پروتکلِ موجود (`service_item_id=15`، کلینیک ۳) دست‌نخورده است.
---
## پیشنهادها
1. **ارتقای `react-router`** — تسک جدا با تست دستی مسیرهای پنل. یافتهٔ ۲.
2. **ردیابی `lodash-es`** — کدام dependency آن را می‌کشد؛ اگر transitive است، override.
3. **sanitize سمت سرور برای بدنهٔ بلاگ** — یافتهٔ ۵ را مستقل از CKEditor می‌بندد.
4. **یک `ClinicStaff` در tenant دوم** — تا آدیت بعدی بتواند IDOR بین‌نقشیِ پرسنل را واقعاً بزند.
5. **تست ساختاری برای گیت کنترلرها** — چیزی شبیه `TenantSchemaCoverageTest` که کنترلر جدیدِ بدون
گیت مجوز را قرمز کند. یافتهٔ ۱ دقیقاً از همین شکاف آمد: `#[IsGranted('IS_AUTHENTICATED_FULLY')]`
سطح-کلاس، درایور را راضی می‌کند ولی هیچ مجوزی را enforce نمی‌کند.
---
*پایان گزارش — درایور `symfony-security-audit` + پروب دستی با JWT واقعی هر نقش + بازرسی کد. هر
یافته بازتولید شده است.*