- 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.
28 KiB
آدیت امنیتی دلتایی — سطح حملهٔ ساختهشده بعد از ۲۰۲۶-۰۷-۱۹
زمینه
آخرین آدیت امنیتی 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) در آن، این
سه سؤال جواب داده شود:
- کدام اندپوینت(ها) این مجوز را نمایندگی میکنند؟
- آیا backend واقعاً enforce میکند، یا فقط UI دکمه را پنهان میکند؟
- آیا منشی و پزشکِ مهمان هر دو 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 با همان ساختار گزارش ۲۰۲۶-۰۷-۱۹:
- خلاصهٔ وضعیت — جدول
# / یافته / شدت / وضعیت - جدول creds نقشها که آدیت با آن اجرا شد
- برای هر یافته: فایل و خط، ریسک، شرح، بازتولید با خروجی واقعی، رفع اعمالشده، علت انتخاب راهحل
- جدول کامل ماتریس مجوز (وظیفهٔ ۴)
- «آنچه سالم بود» — چیزهایی که تست شدند و مشکلی نداشتند
- «leadهایی که رد شدند» با دلیل
- «محدودیت پوشش» — صادقانه، چه چیزی تست نشد و چرا
- «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 واقعی در گزارش ننویس. مقدار را ماسک کن و فقط فایل و خط را بده.