Files
clinicpro/docs/security/AUDIT-2026-08-07.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

407 lines
26 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 — ۲۰۲۶-۰۸-۰۷
**نوع:** آدیت **دلتایی** روی سطح حملهٔ ساخته‌شده بعد از `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 واقعی هر نقش + بازرسی کد. هر
یافته بازتولید شده است.*