Full-stack implementation of the Cyberyan full-stack trial task. Backend (Django 5 / DRF) - Normalised PostgreSQL model (Profile, Company, School, Skill, Experience, Education) as the source of truth, with GIN + trigram indexes. - Self-healing CSV reader: the supplied export carries an injected watermark column, missing columns, rows split by broken quoting and a duplicated header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean records out of 336 physical rows; only 10 rows parse correctly without it. - Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL full-text search as a fallback selected by a single environment variable. - Keyword search with per-field boosts, fuzziness and phrase boosting, six faceted filters (OR within a filter, AND across filters), an experience range, four sort orders, highlighting and facet aggregations. - JWT auth with rotation + blacklist, three roles, PII masking, scoped rate limits, a single error envelope, OpenAPI schema and health probes. Frontend (React 18 / TypeScript / Vite) - Debounced search with type-ahead, faceted filter panel, result cards, profile drawer, URL-synced search state, automatic token refresh. - Highlight snippets rendered without dangerouslySetInnerHTML. Infrastructure - Hardened multi-stage images (non-root, read-only rootfs, healthchecks). - docker-compose for the full stack plus dev and production overlays. - ELK: structured JSON logs shipped asynchronously to Logstash, routed per event type, with a composable index template and a Kibana setup script. Quality - 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc, GitHub Actions running lint, tests and both image builds. - Persian documentation: README plus architecture, search, API, data, security and operations guides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
16 KiB
امنیت
این سند مدل تهدید، لایههای دفاعی و — مهمتر — trade-offهایی که آگاهانه پذیرفته شدهاند را توضیح میدهد. یک سند امنیتی که فقط فهرست کارهای خوب باشد، نصف حقیقت را میگوید.
۱. مدل تهدید
| دارایی | چرا ارزشمند است |
|---|---|
| اطلاعات تماس ۲۸۴ نفر واقعی | ایمیل، موبایل، آدرس خیابان — داده شخصی قابل سوءاستفاده |
| کل دیتاست | ارزش تجاری؛ استخراج انبوه (scraping) تهدید اصلی است |
| حسابهای کاربری | دسترسی به داده بالا |
| زیرساخت | Postgres، Elasticsearch، Kibana |
| مهاجم | توان | دفاع اصلی |
|---|---|---|
| ناشناس از اینترنت | درخواست HTTP | احراز هویت اجباری، rate limit، هدرهای امنیتی |
| کاربر لاگینکرده با نیت بد | API معتبر | ماسک PII، سقف نرخ، صفحهبندی محدود، لاگ ممیزی |
| مهاجم روی شبکه | شنود | HTTPS در لبه، شبکه داخلی داکر |
| فایل ورودی مخرب | محتوای CSV | literal_eval بهجای eval، اعتبارسنجی امضا |
۲. احراز هویت
| مورد | پیادهسازی | دلیل |
|---|---|---|
| هش رمز | Argon2id (اول در PASSWORD_HASHERS) |
توصیه OWASP؛ مقاوم در برابر GPU |
| سیاست رمز | ≥۱۰ کاراکتر + ۴ اعتبارسنج جنگو | جلوی رمزهای فهرستشده را میگیرد |
| عمر access token | ۱۵ دقیقه | پنجره سوءاستفاده از توکن دزدیدهشده کوتاه |
| عمر refresh token | ۷ روز، چرخشی | هر refresh یک جفت جدید میدهد |
| بلاکلیست | ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION |
استفاده دوباره از توکن قدیمی رد میشود ⇒ نشت قابل تشخیص است |
| الگوریتم | HS256 با کلید جدا (JWT_SIGNING_KEY) |
چرخاندن کلید JWT بدون بیاعتبار کردن session جنگو |
| کلیدهای داخل توکن | فقط user_id، role، email |
payload کوچک؛ هیچ داده حساسی در توکنی که کلاینت میبیند نیست |
چرا sessionStorage و نه کوکی؟
توکنها در sessionStorage نگه داشته میشوند:
- ✅ بهصورت خودکار به هیچ درخواست cross-site پیوست نمیشوند ⇒ کل کلاس حملات CSRF منتفی است و API به محافظت CSRF برای JSON نیاز ندارد.
- ✅ با بستن تب پاک میشوند.
- ⚠️ در برابر XSS آسیبپذیرند — بههمین دلیل CSP سختگیرانه است، هیچجا
dangerouslySetInnerHTMLاستفاده نشده، و React بهصورت پیشفرض خروجی را escape میکند.
جایگزین «کوکی HttpOnly + SameSite=Strict» در برابر XSS بهتر است ولی CSRF را برمیگرداند و برای SPA روی دامنه دیگر پیچیده میشود. برای این معماری (SPA و API هممبدأ، بدون کوکی) انتخاب فعلی سازگارتر است. در یک سیستم با ریسک بالاتر، کوکی HttpOnly + محافظت CSRF گزینه درستتر است.
۳. مجوزدهی
class Role(models.TextChoices):
ADMIN = "admin" # همهچیز + بازسازی ایندکس + PII
ANALYST = "analyst" # سرچ + PII
VIEWER = "viewer" # سرچ، PII ماسکشده
| قاعده | جای اعمال |
|---|---|
| deny by default — هر endpoint لاگین میخواهد مگر صریحاً باز باشد | DEFAULT_PERMISSION_CLASSES = (IsAuthenticated,) |
ثبتنام همیشه viewer میدهد |
RegisterSerializer.create() |
بازسازی ایندکس فقط admin |
IsAdminRole روی ReindexView |
دیدن PII فقط analyst/admin |
User.can_view_pii + PIIMixin |
role یک فیلد ساده است نه گروه جنگو. با سه نقش، این انتخاب باعث میشود مجوزها را بشود در یک نگاه خواند و در یک خط تست کرد — و payload توکن کوچک بماند.
۴. حریم خصوصی داده
۴.۱ ماسککردن
joseph.holland@garverusa.com → jo************@garverusa.com
+19402058928 → +1940*****28
ماسک در لایه سریالایز انجام میشود، پس مقدار خام هیچوقت از دیتابیس برای کاربر بیمجوز خارج نمیشود. تست test_privacy.py تضمین میکند بخش محلی ایمیل و میانه شماره واقعاً پنهان میمانند (و برای بخش محلی یککاراکتری، کل آن پنهان میشود).
۴.۲ PII در Elasticsearch نیست
این تصمیم مهمتر از ماسککردن است. اگر ایمیل ایندکس شود، هر کاربر لاگینکرده میتواند با سرچ حدسی (q=john.smith@acme.com) وجود یک آدرس را تأیید کند، بدون اینکه هیچوقت آن را ببیند. این یک oracle است و ماسککردن جلویش را نمیگیرد.
راهحل: build_document() اصلاً این فیلدها را نمیسازد و test_document_never_carries_contact_details آن را قفل کرده است.
۴.۳ لاگها
pipeline لاگاستش فیلدهای password، authorization، emails و phone_numbers را حذف میکند — یک شبکه ایمنی برای وقتی که کسی بهاشتباه چیزی را لاگ کند. لاگ ممیزی سرچ، کوئری و فیلترها را ثبت میکند (که برای تحلیل رفتار لازم است) ولی هیچ داده تماسی را.
۵. اعتبارسنجی ورودی
| ورودی | محافظ |
|---|---|
q |
حداکثر ۲۰۰ کاراکتر |
| هر فیلتر | حداکثر ۲۰ مقدار، هر مقدار ۱۲۸ کاراکتر |
page_size |
حداکثر ۱۰۰ |
page × page_size |
حداکثر ۱۰٬۰۰۰ (رد میشود، نه اینکه بیصدا کوتاه شود) |
| بدنه درخواست | DATA_UPLOAD_MAX_MEMORY_SIZE = 5MB، MAX_NUMBER_FIELDS = 1000 |
X-Request-ID |
حداکثر ۶۴ کاراکتر و فقط alphanumeric/-/_؛ وگرنه دور ریخته و تولید میشود |
| خط درخواست HTTP | limit_request_line = 8190 در gunicorn |
تزریق SQL: همه کوئریها از ORM جنگو میگذرند. تنها SQL خام پروژه دو UPDATE بدون پارامتر کاربر در _refresh_skill_counters() است.
تزریق کوئری Elasticsearch: ورودی کاربر همیشه بهعنوان مقدار در ساختار کوئری قرار میگیرد، هیچوقت بهعنوان JSON الحاقشده. query_string (که نحو قابل سوءاستفاده دارد) استفاده نشده — فقط multi_match و terms.
۶. هدرها و سیاستها
Django (تنظیمات پروداکشن)
SECURE_HSTS_SECONDS = 31_536_000 # یک سال
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
SECURE_SSL_REDIRECT = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
X_FRAME_OPTIONS = "DENY"
SESSION_COOKIE_SECURE = CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = CSRF_COOKIE_SAMESITE = "Strict"
nginx
Content-Security-Policy: default-src 'self'; script-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self';
font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()
server_tokens off;
'unsafe-inline' فقط برای style-src باقی مانده (React برای چند استایل inline در JSX از آن استفاده میکند). script-src کاملاً 'self' است، یعنی هیچ اسکریپت inline یا خارجی اجرا نمیشود.
CORS: چون SPA و API هممبدأ هستند، در پروداکشن CORS_ALLOW_ALL_ORIGINS = False و لیست مبدأ خالی میماند. CORS فقط برای حالت توسعه (npm run dev روی پورت ۵۱۷۳) لازم است.
۷. کانتینرها و شبکه
| اقدام | جزئیات |
|---|---|
| بیلد چندمرحلهای | کامپایلر و ابزار بیلد در ایمیج نهایی نیستند |
| کاربر غیرroot | app (uid 10001) در بکاند، nginx در فرانتاند |
no-new-privileges |
روی هر دو سرویس اپلیکیشن |
| فایلسیستم read-only | فرانتاند، با tmpfs برای /tmp، /var/run، /var/cache/nginx |
| بدون shell در مسیر حساس | useradd --shell /usr/sbin/nologin |
| healthcheck | هر سرویس؛ depends_on: condition: service_healthy |
| سقف لاگ | max-size: 10m, max-file: 3 ⇒ لاگ دیسک را پر نمیکند |
توپولوژی شبکه
┌─ frontend-net ──────────────┐ ┌─ backend-net (internal: true) ─────────┐
│ frontend ⟷ backend │ │ postgres redis elasticsearch │
│ ▲ │ │ logstash backend │
└─────┼───────────────────────┘ └────────────────────────────────────────┘
│ :8080 منتشرشده ▲ هیچ مسیری به میزبان ندارد
میزبان │
┌─ elk-net ─────────────┐
│ elasticsearch │
│ logstash kibana │
└───────────────────────┘
▲ kibana فقط روی 127.0.0.1:5601
backend-net با internal: true تعریف شده: کانتینرهای روی آن هیچ مسیری به بیرون ندارند. Postgres و Redis هیچ پورتی منتشر نمیکنند. Kibana فقط به 127.0.0.1 بایند شده، نه 0.0.0.0.
۸. مدیریت اسرار
- هیچ رمزی در کد نیست؛ همه از متغیر محیطی خوانده میشوند.
.envدر.gitignoreو.dockerignoreاست؛ فقط.env.exampleنگهداری میشود.docker-compose.ymlبرای متغیرهای حیاتی از نگارش «اجباری» استفاده میکند:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}— بدون آن، استک اصلاً بالا نمیآید.settings/prod.pyروی کلید کوتاهتر از ۵۰ کاراکتر یاDEBUG=Trueکرش میکند.- hook
detect-private-keyدر pre-commit جلوی کامیت کلید خصوصی را میگیرد.
۹. Trade-offهای پذیرفتهشده
۹.۱ احراز هویت Elasticsearch در حالت پیشفرض خاموش است
واقعیت: در docker-compose.yml مقدار xpack.security.enabled: "false" است.
چرا: روشن کردنش نیازمند مدیریت گواهی، تنظیم رمز کاربر kibana_system و یک کانتینر setup است. برای پروژهای که هدفش «با یک دستور اجرا شود» است، این چهار نقطه شکست اضافه میکند — و رایجترین دلیلی است که یک تسک آزمایشی روی ماشین ارزیاب بالا نمیآید.
چه چیزی جبرانش میکند: کلاستر روی شبکه internal داکر است و هیچ پورتی منتشر نمیکند. از خارج کانتینرها بههیچوجه در دسترس نیست.
راه درست برای پروداکشن: overlay آماده است —
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
که xpack.security.enabled=true میکند، به هر سه کلاینت (بکاند، Logstash، Kibana) اعتبارنامه میدهد، پورت Kibana را برمیدارد و محدودیت منابع اعمال میکند.
۹.۲ TLS بین کانتینرها نیست
ترافیک داخلی شبکه داکر رمز نشده است. در یک استقرار واقعی این کار به service mesh یا load balancer داخلی سپرده میشود، نه به هر سرویس بهصورت جداگانه. HTTPS در لبه (nginx یا load balancer بالادست) الزامی است و SECURE_SSL_REDIRECT + HSTS در تنظیمات پروداکشن آن را تحمیل میکنند.
۹.۳ دیتاست داخل ایمیج است
فایل CSV در ایمیج بکاند کپی میشود تا docker compose up بدون هیچ مرحله دستی داده داشته باشد. در پروداکشن، داده باید از منبع خارجی (S3، دیتابیس مبدأ) بیاید و ایمیج فقط کد باشد. برای یک تسک ارزیابی، سادگی اجرا مهمتر است — و SEED_DATASET=false در overlay پروداکشن این رفتار را خاموش میکند.
۹.۴ رمزهای نمونه در .env.example
رمزهای کاربران دمو در فایل نمونه هستند تا ارزیاب بتواند بلافاصله لاگین کند. این فایل نمونه است، نه .env واقعی، و README صریحاً هشدار میدهد که قبل از هر استقرار عوض شوند.
۱۰. امنیت در CI
- bandit -q -c pyproject.toml -r apps config # تحلیل ایستای امنیتی پایتون
- pip-audit -r requirements/base.txt # CVE در وابستگیهای پایتون
- npm audit --omit=dev # CVE در وابستگیهای فرانتاند
- python manage.py check --deploy # چکلیست استقرار خود جنگو
ruff هم قواعد S (bandit) را در lint معمولی اجرا میکند، پس مشکلات رایج قبل از commit گرفته میشوند.
۱۱. اگر فردا مسئولش میشدم
فهرست کارهایی که برای یک استقرار واقعی لازماند و اینجا انجام نشدهاند — چون صادق بودن دربارهشان بهتر از وانمود کردن است:
- احراز هویت Elasticsearch را روشن و TLS داخلی را اضافه کن (overlay آماده است).
- اسرار را به یک مدیر راز (Vault / AWS Secrets Manager) منتقل کن؛ متغیر محیطی برای اسرار بلندمدت کافی نیست.
- احراز هویت دومرحلهای برای نقش
admin. - قفل حساب بعد از N تلاش ناموفق (الان فقط rate limit روی IP هست).
- سیاست نگهداری داده: حذف خودکار لاگهای قدیمیتر از ۳۰ روز با ILM.
- اسکن ایمیجها (Trivy) در CI.
- امضای ایمیج و SBOM.
- هشدار روی الگوهای مشکوک: یک کاربر که در یک ساعت ۱۰٬۰۰۰ پروفایل باز میکند (دادهاش در لاگ ممیزی هست، فقط قاعده هشدار لازم است).