Files
clinicpro/.claude/prompt/security-audit-delta-2026-08.md
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

28 KiB
Raw Permalink Blame History

آدیت امنیتی دلتایی — سطح حملهٔ ساخته‌شده بعد از ۲۰۲۶-۰۷-۱۹

زمینه

آخرین آدیت امنیتی 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 کلاسش فقط با احراز هویت گارد شده و مجوز را متد‌به‌متد می‌سنجد:

// 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:

// 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 مسیر

// 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 نامعلوم است؛ اول تلاش، بعد در صورت نیاز ست کردن هش.


وظایف

۱. آماده‌سازی ماتریس نقش‌ها

اول وضعیت واقعی اکانت‌ها را بسنج، بعد کمبود را بساز:

ddev start
node .claude/skills/qa-clinicpro/driver.mjs roles

کاربر «فقط پرسنل» (09128726723) و کاربر چندنقشی (0912000201) از قبل در DB هستند — ساخته نمی‌شوند. فقط پسوردشان باید معلوم شود. اگر پسورد نامعلوم بود، هش را ست کن:

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 که «رفع شد» علامت خورده، بازتولیدِ همان گزارش را دوباره اجرا کن و ثابت کن هنوز بسته است:

# 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 خاموش کن، بعد اندپوینت متناظر را مستقیم صدا بزن.

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، یا مجوز اکشن هم؟

# با توکن منشیِ بدون مجوز 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.

# 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) و تأیید اینکه پارامتری‌سازی شده.


۷. آدیت گاردِ پرسنل

الف) نرمال‌سازی مسیر (بخش «وضعیت فعلی ۲»):

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 است.

ب) کشف مسیرهای تازه‌ای که گارد نمی‌بیند. فهرست کامل روت‌ها را بگیر و همه را با توکن پرسنل بزن:

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 در کنسول نمی‌دهند.
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.

نحوه تست:

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 واقعی در گزارش ننویس. مقدار را ماسک کن و فقط فایل و خط را بده.