Files
clinicpro/docs/security/AUDIT-2026-08-07.md
T

36 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 رفع شد (مهاجرت به v8)
3 lodash-es — code injection در _.template + دو prototype pollution 🟧 High رفع شد (override به 4.18.1)
4 ۶۲ moderate در @ckeditor/ckeditor5-build-classic (deprecated) 🟨 Medium ⚠️ risk پذیرفته‌شده — تصمیم ۲۰۲۶-۰۸-۰۷
5 dangerouslySetInnerHTML روی بدنهٔ بلاگ در BlogReviewPage 🟨 Medium رفع شد (sanitize هنگام ذخیره)
6 APP_SECRET واقعی در .env.test تحت git 🟦 Low رفع شد
7 پسورد sandbox درگاه ملت هاردکد Info رفع شد (به env منتقل شد)
8 ۱۰ روت GET که مجوزِ رجیستری‌شان را enforce نمی‌کنند 🟨 Medium ⚠️ باز — با تست baseline مهار شد

سیاست اولیه «فقط Critical/High رفع شود» بود؛ کاربر بعداً رفعِ همهٔ یافته‌های باز را خواست، پس یافته‌های ۲، ۳، ۵، ۶ و ۷ هم بسته شدند. یافتهٔ ۴ طبق تصمیم صریح خارج از محدوده ماند و یافتهٔ ۸ حین همین کار کشف شد.

npm audit --omit=dev    # قبل: high=2  moderate=62      بعد: high=0  moderate=3
ddev exec php bin/phpunit                               # ۱۵۵۵ تست، ۴۸۳۲ assertion، سبز

ماتریس نقش‌هایی که آدیت با آن اجرا شد

آدیت ۲۰۲۶-۰۷-۱۹ در بخش «محدودیت پوشش» نوشته بود ماتریس 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-dom@7.17.0 به react-router@8.3.0.

نسخهٔ امن فقط > 8.2.0 است و در v8 پکیج react-router-dom دیگر منتشر نمی‌شود (آخرین نسخه‌اش 7.18.2 است) — یعنی bump ساده ممکن نبود و مهاجرت اجباری بود:

npm pkg delete dependencies.react-router-dom
npm pkg set dependencies.react-router="^8.3.0"
# ۸۱ فایل: from 'react-router-dom' → from 'react-router'

۶. چرا کم‌ریسک بود: کل APIهای مصرف‌شده در پنل ۱۱ تاست و همه در v8 دست‌نخورده‌اند — BrowserRouter, MemoryRouter, Routes, Route, Link, NavLink, Navigate, Outlet, useLocation, useNavigate, useParams, useSearchParams. هیچ API حذف‌شده‌ای در پروژه استفاده نمی‌شد، پس مهاجرت فقط تغییرِ نامِ ماژول بود نه بازنویسیِ روتینگ.

۷. تأیید:

npx tsc --noEmit                 # بدون خطا
ddev exec yarn dev               # webpack compiled successfully — 54 فایل
npx vitest --run                 # 802 تست فرانت‌اند

هفت تستِ فرانت‌اند در اجرای موازیِ اول timeout دادند؛ با --no-file-parallelism هر ۵۴ تستِ همان فایل‌ها سبز شد. یعنی گرسنگی منابع بود نه رگرسیونِ روتینگ.


3. 🟧 HIGH — lodash-es هنوز آسیب‌پذیر

۱. فایل: package.jsonlodash-es (<=4.17.23)

۲. شرح: در آدیت ۲۰۲۶-۰۷-۱۹ «نیمه‌رفع» ثبت شده بود. هنوز باز است، و حالا دو advisory دیگر هم اضافه شده: prototype pollution در _.unset و _.omit.

۳. بازتولید: همان npm audit بالا — high=2 که یکی react-router است و یکی lodash-es.

۴. منشأ: ردیابی شد — کاملاً transitive و فقط از یک جا می‌آید:

clinicpro
└─┬ @ckeditor/ckeditor5-build-classic@44.3.0
  └── lodash-es@4.17.21   (در ده‌ها زیرپکیج dedupe شده)

۵. رفع اعمال‌شده: چون هیچ dependency مستقیمی نیست، ارتقای مستقیم ممکن نبود. نسخهٔ امن 4.18.1 است (range آسیب‌پذیر <=4.17.23)، پس با override اعمال شد:

"overrides": { "lodash": "^4.17.21", "lodash-es": "^4.18.1" }

۶. چرا override و نه ارتقای CKEditor: بستنِ این یافته از راه CKEditor یعنی همان مهاجرتی که در یافتهٔ ۴ عمداً خارج از محدوده گذاشته شد. override همان مشکل را بدون لمس‌کردن ادیتور می‌بندد. lodash-es در بازهٔ 4.17→4.18 شکستِ API ندارد و CKEditor فقط از توابع پایه‌اش استفاده می‌کند.

۷. تأیید: npm audit --omit=dev دیگر lodash-es را گزارش نمی‌کند؛ build و ۸۰۲ تست فرانت‌اند سبز.


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، همین خط.

۴. رفع اعمال‌شده: پاک‌سازی HTML در لحظهٔ ذخیره با symfony/html-sanitizer.

  • config/packages/html_sanitizer.yaml — سیاست blog.body: فهرست سفیدِ عناصری که CKEditor واقعاً تولید می‌کند، طرح‌های مجاز فقط http/https/mailto، و rel="noopener noreferrer" اجباری روی هر <a>.
  • src/Blog/Service/BlogBodySanitizer.php — سرویس نازک روی همان sanitizer.
  • هر چهار نقطهٔ ورودِ بدنه: ساخت و ویرایش در BlogController و در RepresentationBlogController.

۵. دو تصمیم طراحی:

  • ذخیره، نه نمایش. بدنه چند مصرف‌کننده دارد — پنل ادمین، سایت عمومی nobat724_front و فید. اگر پاک‌سازی در لایهٔ نمایش بود، هر مصرف‌کنندهٔ تازه دوباره آسیب‌پذیر شروع می‌کرد.
  • drop_elements نه block_elements. اولین پیاده‌سازی block بود و تست قرمز شد: block تگ را برمی‌دارد ولی متنِ داخلش را نگه می‌دارد، پس <script>alert(1)</script> به متنِ alert(1) تبدیل می‌شد. برای این عناصر خودِ محتوا هم باید برود.

۶. پاک‌سازی پیش از سنجشِ خالی‌بودن: بدنه‌ای که چیزی جز markup ناامن ندارد، بعد از پاک‌سازی خالی می‌شود و باید همان ۴۲۲ «الزامی است» را بگیرد، نه اینکه خالی ذخیره شود.

۷. تست: tests/Blog/BlogBodySanitizerTest.php — پنج تست: حذف <script>، حذف onclick و onerror و javascript:، بقای متنِ غنیِ سالم به‌همراه noopener، پاک‌سازی مسیر ویرایش، و ۴۲۲ برای بدنهٔ کاملاً ناامن. کل tests/Blog/: ۵۱ تست سبز.


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 که یافتهٔ ۳ گزارش قبلی بود، تمیز است — آن رفع پابرجاست.

رفع: مقدار به not-a-secret-test-env-only تغییر کرد. آشکارا غیرعملیاتی بودنِ مقدار خودش جلوی این را می‌گیرد که کسی این فایل را منبع یک secret واقعی بپندارد.


7. INFO — پسورد sandbox درگاه ملت

۱. فایل: src/Payment/Gateway/MellatGateway.php:23private const SANDBOX_PASSWORD = '17384843'. همان یافتهٔ ۵ گزارش قبلی.

۲. ماهیت: اعتبارنامهٔ نمایشیِ banktest.ir است — عمومی و منتشرشده در مستندات خودشان، پس افشای secret نیست. ولی رشتهٔ پسوردمانند در src/ هم اسکنر را روشن می‌کند و هم جایگزینی با ترمینال تستِ دیگر را نیازمند ویرایش کد می‌کند.

۳. رفع اعمال‌شده: هر سه مقدار به constructor منتقل شدند و از env می‌آیند:

# config/services.yaml
$sandboxTerminalId: '%env(default::MELLAT_SANDBOX_TERMINAL_ID)%'
$sandboxUsername:   '%env(default::MELLAT_SANDBOX_USERNAME)%'
$sandboxPassword:   '%env(default::MELLAT_SANDBOX_PASSWORD)%'

شناسهٔ ترمینال و نام کاربری پیش‌فرضِ درون‌کد دارند (شناسه‌اند، نه اعتبارنامه)، ولی پسورد عمداً پیش‌فرضِ درون‌کد ندارد: نبودنِ env یعنی sandbox پیکربندی نشده، و آن بهتر از نگه‌داشتن رشتهٔ پسوردمانند در src/ است. مقدار نمایشی در .env با توضیح صریح نشست.

۴. تأیید: tests/Payment/ — ۱۴ تست سبز.


8. 🟨 MEDIUM — ده روت GET که مجوزِ رجیستری‌شان را enforce نمی‌کنند

۱. کشف چطور شد: حین ساختِ تور ایمنیِ ساختاری (پیشنهاد ۵ همین گزارش). تست یک منشی می‌سازد که هر مجوزِ PermissionCatalog برایش خاموش است و هر روتِ GET بدون path parameter را با او می‌زند. ۲۹ روت پاسخ موفق دادند؛ پس از triage روی محتوای پاسخ، ۱۹ تای‌شان موجه بودند (دادهٔ مرجعِ ثابت، اندپوینت عمومی، یا دادهٔ خودِ کاربر) و ۱۰ تا نه:

روت مجوزی که باید enforce شود
/api/v1/appointments/user appointments.view
/api/v1/my/appointments appointments.view
/api/v1/my/appointments/today-stats appointments.view
/api/v1/my/billing/payments payments.view
/api/v1/my/billing/payments/summary payments.view
/api/v1/billing/claims payments.view
/api/v1/billing/claims/by-patient payments.view
/api/v1/billing/reports/insurance-debt payments.view
/api/v1/doctor-services services.view
/api/v1/insurances insurances.view

۲. ریسک: همان الگوی یافتهٔ ۱، در مقیاس بزرگ‌تر: منشی‌ای که توگلِ «نوبت‌ها» یا «پرداخت‌ها» برایش بسته است، با درخواست مستقیم به API همان داده را می‌گیرد. توگل فقط دکمه را در پنل پنهان می‌کند.

۳. چرا در DB تست خالی به نظر می‌رسد: tenantِ تستی داده ندارد، پس پاسخ {"data":[]} است. این دلیل امن‌بودن نیست — در tenant واقعی دادهٔ واقعی برمی‌گردد.

۴. وضعیت: باز، ولی مهارشده. رفع نشد چون هر سه کنترلرِ درگیر (BillingController، MyAppointmentsController، DoctorServiceController) هیچ checker مجوزی تزریق‌شده ندارند؛ بستنشان بدون دانستنِ نیازِ واقعیِ پنل ریسکِ شکستنِ صفحه دارد — همان‌طور که docs/api/ برای فهرست منابع مستند کرده که appointments.view عمداً درش را باز می‌کند. این تصمیم باید با دیدنِ مصرفِ واقعیِ پنل گرفته شود، نه در یک پاس امنیتی.

به‌جایش در ApiLeastPrivilegeTest::KNOWN_GAPS ثبت شدند. نقش آن فهرست مثل baseline است: تست اجازه می‌دهد همین ده تا ۲۰۰ بدهند، ولی بزرگ‌ترشدنش را نمی‌پذیرد. یک تست دوم هم هست که اگر گَپی بسته شد، قرمز می‌شود تا ردیفش از فهرست حذف شود — وگرنه baseline برای همیشه می‌ماند و کسی نمی‌فهمد بدهی تسویه شده.


آنچه سالم بود (تست شد، یافته نیست)

۱. جداسازی 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
src/Blog/Service/BlogBodySanitizer.php                     سرویس پاک‌سازی بدنهٔ مقاله (جدید)
src/Blog/Controller/BlogController.php                     پاک‌سازی در ساخت و ویرایش
src/Blog/Controller/RepresentationBlogController.php       پاک‌سازی در ساخت و ویرایش
src/Payment/Gateway/MellatGateway.php                      اعتبارنامهٔ sandbox از env
config/packages/html_sanitizer.yaml                        سیاست blog.body (جدید)
config/services.yaml                                       سه env تازهٔ MELLAT_SANDBOX_*
package.json                                               react-router@8، override lodash-es
assets/admin/**                                            ۸۱ فایل: react-router-dom → react-router
.env / .env.test                                           مقادیر نمایشی با توضیح صریح
tests/Secretary/SecretaryResourceEnforcementTest.php       ۴ تست رگرسیون پروتکل درمان
tests/Blog/BlogBodySanitizerTest.php                       ۵ تست XSS (جدید)
tests/Shared/ApiLeastPrivilegeTest.php                     تور ایمنی ساختاری + baseline (جدید)
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، همه از قبل موجود).

یک درسِ جانبی از خودِ تست ساختاری: اولین نسخه‌اش کلِ سوییت را قرمز کرد — نه خودش، بلکه TreatmentCaseOpenerTest که ۲۰۰ تست بعدتر اجرا می‌شود. علتش این بود که این تست ~۱۳۰ درخواست پشت‌سرهم می‌زند و هر درخواست کرنل را دوباره بالا می‌آورد، پس $this->em کهنه می‌شد و همان نمونه به تست بعدی ارث می‌رسید. با resetManager() در tearDown بسته شد — دقیقاً همان دامی که ApiTestCase::setUp قبلاً برای «EntityManager is closed» بسته بود.

phpstan: ۱۷ خطا، همان ۱۷ تای پیش از این آدیت. هیچ‌کدام در فایل‌های لمس‌شدهٔ این جلسه نیستند.

دادهٔ DB: پروب DELETE روی سرویسِ کلینیک ۱ اجرا شد؛ آن سرویس پروتکل نداشت، پس چیزی حذف نشد. تنها پروتکلِ موجود (service_item_id=15، کلینیک ۳) دست‌نخورده است.


پیشنهادها

  1. بستنِ ده گَپِ یافتهٔ ۸ — با دیدنِ مصرفِ واقعیِ پنل تصمیم بگیر هر کدام کدام مجوز را بخواهد، بعد ردیفش را از KNOWN_GAPS بردار. تست دوم خودش یادآوری می‌کند.
  2. مهاجرت CKEditor به پکیج umbrella ckeditor5 v45+ — تنها راهِ بستنِ یافتهٔ ۴.
  3. یک ClinicStaff در tenant دوم — تا آدیت بعدی بتواند IDOR بین‌نقشیِ پرسنل را واقعاً بزند.
  4. گسترشِ ApiLeastPrivilegeTest به روت‌های نوشتنی — الان فقط GET بدون path parameter را پوشش می‌دهد. POST/PATCH/DELETE بدنهٔ معتبر می‌خواهند، ولی همان‌ها خطرناک‌ترند.
  5. پاک‌سازیِ ۱۷ خطای phpstan — بدهیِ قدیمی، بی‌ربط به امنیت، ولی مانعِ «سبز یعنی سبز» است.

پایان گزارش — درایور symfony-security-audit + پروب دستی با JWT واقعی هر نقش + بازرسی کد. هر یافته بازتولید شده است.