Files
mobiniqow a464e550c9 feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
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
2026-09-03 08:13:50 +00:00

310 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# امنیت
<div dir="rtl">
این سند مدل تهدید، لایه‌های دفاعی و — مهم‌تر — **trade-offهایی که آگاهانه پذیرفته شده‌اند** را توضیح می‌دهد. یک سند امنیتی که فقط فهرست کارهای خوب باشد، نصف حقیقت را می‌گوید.
</div>
---
## ۱. مدل تهدید
<div dir="rtl">
| دارایی | چرا ارزشمند است |
|---|---|
| اطلاعات تماس ۲۸۴ نفر واقعی | ایمیل، موبایل، آدرس خیابان — داده شخصی قابل سوءاستفاده |
| کل دیتاست | ارزش تجاری؛ استخراج انبوه (scraping) تهدید اصلی است |
| حساب‌های کاربری | دسترسی به داده بالا |
| زیرساخت | Postgres، Elasticsearch، Kibana |
| مهاجم | توان | دفاع اصلی |
|---|---|---|
| ناشناس از اینترنت | درخواست HTTP | احراز هویت اجباری، rate limit، هدرهای امنیتی |
| کاربر لاگین‌کرده با نیت بد | API معتبر | ماسک PII، سقف نرخ، صفحه‌بندی محدود، لاگ ممیزی |
| مهاجم روی شبکه | شنود | HTTPS در لبه، شبکه داخلی داکر |
| فایل ورودی مخرب | محتوای CSV | `literal_eval` به‌جای `eval`، اعتبارسنجی امضا |
</div>
---
## ۲. احراز هویت
<div dir="rtl">
| مورد | پیاده‌سازی | دلیل |
|---|---|---|
| هش رمز | **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 گزینه درست‌تر است.
</div>
---
## ۳. مجوزدهی
```python
class Role(models.TextChoices):
ADMIN = "admin" # همه‌چیز + بازسازی ایندکس + PII
ANALYST = "analyst" # سرچ + PII
VIEWER = "viewer" # سرچ، PII ماسک‌شده
```
<div dir="rtl">
| قاعده | جای اعمال |
|---|---|
| **deny by default** — هر endpoint لاگین می‌خواهد مگر صریحاً باز باشد | `DEFAULT_PERMISSION_CLASSES = (IsAuthenticated,)` |
| ثبت‌نام همیشه `viewer` می‌دهد | `RegisterSerializer.create()` |
| بازسازی ایندکس فقط `admin` | `IsAdminRole` روی `ReindexView` |
| دیدن PII فقط `analyst`/`admin` | `User.can_view_pii` + `PIIMixin` |
`role` یک فیلد ساده است نه گروه جنگو. با سه نقش، این انتخاب باعث می‌شود مجوزها را بشود در یک نگاه خواند و در یک خط تست کرد — و payload توکن کوچک بماند.
</div>
---
## ۴. حریم خصوصی داده
### ۴.۱ ماسک‌کردن
```
joseph.holland@garverusa.com → jo************@garverusa.com
+19402058928 → +1940*****28
```
<div dir="rtl">
ماسک در لایه **سریالایز** انجام می‌شود، پس مقدار خام هیچ‌وقت از دیتابیس برای کاربر بی‌مجوز خارج نمی‌شود. تست `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` را حذف می‌کند — یک شبکه ایمنی برای وقتی که کسی به‌اشتباه چیزی را لاگ کند. لاگ ممیزی سرچ، **کوئری و فیلترها** را ثبت می‌کند (که برای تحلیل رفتار لازم است) ولی هیچ داده تماسی را.
</div>
---
## ۵. اعتبارسنجی ورودی
<div dir="rtl">
| ورودی | محافظ |
|---|---|
| `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`.
</div>
---
## ۶. هدرها و سیاست‌ها
<div dir="rtl">
**Django (تنظیمات پروداکشن)**
</div>
```python
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"
```
<div dir="rtl">
**nginx**
</div>
```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;
```
<div dir="rtl">
`'unsafe-inline'` فقط برای `style-src` باقی مانده (React برای چند استایل inline در JSX از آن استفاده می‌کند). `script-src` کاملاً `'self'` است، یعنی هیچ اسکریپت inline یا خارجی اجرا نمی‌شود.
**CORS:** چون SPA و API هم‌مبدأ هستند، در پروداکشن `CORS_ALLOW_ALL_ORIGINS = False` و لیست مبدأ خالی می‌ماند. CORS فقط برای حالت توسعه (`npm run dev` روی پورت ۵۱۷۳) لازم است.
</div>
---
## ۷. کانتینرها و شبکه
<div dir="rtl">
| اقدام | جزئیات |
|---|---|
| بیلد چندمرحله‌ای | کامپایلر و ابزار بیلد در ایمیج نهایی نیستند |
| کاربر غیر‌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` ⇒ لاگ دیسک را پر نمی‌کند |
**توپولوژی شبکه**
</div>
```
┌─ 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
```
<div dir="rtl">
`backend-net` با `internal: true` تعریف شده: کانتینرهای روی آن هیچ مسیری به بیرون ندارند. Postgres و Redis هیچ پورتی منتشر نمی‌کنند. Kibana فقط به `127.0.0.1` بایند شده، نه `0.0.0.0`.
</div>
---
## ۸. مدیریت اسرار
<div dir="rtl">
- هیچ رمزی در کد نیست؛ همه از متغیر محیطی خوانده می‌شوند.
- `.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 جلوی کامیت کلید خصوصی را می‌گیرد.
</div>
---
## ۹. Trade-offهای پذیرفته‌شده
<div dir="rtl">
### ۹.۱ احراز هویت Elasticsearch در حالت پیش‌فرض خاموش است
**واقعیت:** در `docker-compose.yml` مقدار `xpack.security.enabled: "false"` است.
**چرا:** روشن کردنش نیازمند مدیریت گواهی، تنظیم رمز کاربر `kibana_system` و یک کانتینر setup است. برای پروژه‌ای که هدفش «با یک دستور اجرا شود» است، این چهار نقطه شکست اضافه می‌کند — و رایج‌ترین دلیلی است که یک تسک آزمایشی روی ماشین ارزیاب بالا نمی‌آید.
**چه چیزی جبرانش می‌کند:** کلاستر روی شبکه `internal` داکر است و هیچ پورتی منتشر نمی‌کند. از خارج کانتینرها به‌هیچ‌وجه در دسترس نیست.
**راه درست برای پروداکشن:** overlay آماده است —
</div>
```bash
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
```
<div dir="rtl">
که `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 صریحاً هشدار می‌دهد که قبل از هر استقرار عوض شوند.
</div>
---
## ۱۰. امنیت در CI
```yaml
- 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 # چک‌لیست استقرار خود جنگو
```
<div dir="rtl">
ruff هم قواعد `S` (bandit) را در lint معمولی اجرا می‌کند، پس مشکلات رایج قبل از commit گرفته می‌شوند.
</div>
---
## ۱۱. اگر فردا مسئولش می‌شدم
<div dir="rtl">
فهرست کارهایی که برای یک استقرار واقعی لازم‌اند و اینجا انجام نشده‌اند — چون صادق بودن درباره‌شان بهتر از وانمود کردن است:
1. احراز هویت Elasticsearch را روشن و TLS داخلی را اضافه کن (overlay آماده است).
2. اسرار را به یک مدیر راز (Vault / AWS Secrets Manager) منتقل کن؛ متغیر محیطی برای اسرار بلندمدت کافی نیست.
3. احراز هویت دومرحله‌ای برای نقش `admin`.
4. قفل حساب بعد از N تلاش ناموفق (الان فقط rate limit روی IP هست).
5. سیاست نگهداری داده: حذف خودکار لاگ‌های قدیمی‌تر از ۳۰ روز با ILM.
6. اسکن ایمیج‌ها (Trivy) در CI.
7. امضای ایمیج و SBOM.
8. هشدار روی الگوهای مشکوک: یک کاربر که در یک ساعت ۱۰٬۰۰۰ پروفایل باز می‌کند (داده‌اش در لاگ ممیزی هست، فقط قاعده هشدار لازم است).
</div>