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

26 KiB
Raw Blame History

گزارش آدیت امنیتی 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 را می‌سنجد:

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

private function denyUnlessGranted(User $user, string $action): void
{
    $this->secretaryAccess->denyUnlessGranted($user, 'services', $action);
    $this->clinicDoctorAccess->denyUnlessGranted($user, 'services', $action);
}

و صدا زدنش در ابتدای هر سه متد: showview، replaceupdate، deleteupdate.

۷. علت انتخاب راه‌حل:

  • منبع 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.phptestTreatmentProtocolReadDeniedByDefault، testTreatmentProtocolReadAllowedWhenServicesGranted، testTreatmentProtocolWriteNeedsServicesUpdate، testTreatmentProtocolWriteAllowedWhenServicesUpdateGranted. کل فایل: ۳۳ تست، ۴۸ assertion، سبز.

۱۰. مستندات: docs/api/treatment.md — هر سه اندپوینت با مجوز جدید و دلیل انتخابش.


2. 🟧 HIGH — react-router با پنج advisory باز

۱. فایل: package.jsonreact-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-q6mhRSCErrorHandler بدون اعتبارسنجی protocol (XSS)
  • GHSA-337j-9hxr-rhxg — تزریق constructor دلخواه در deserializeErrors()
  • GHSA-chx6-hx7r-mcp5 — DoS احرازنشده با route matching ناکارآمد
  • GHSA-qwww-vcr4-c8h2 — دور زدن CSRF در حالت RSC

۴. بازتولید:

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.jsonlodash-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

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

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:23private 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:66QaTest@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 واقعی هر نقش + بازرسی کد. هر یافته بازتولید شده است.