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
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
# امنیت
|
||||
|
||||
<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>
|
||||
Reference in New Issue
Block a user