Files
ZBrain/docs/SECURITY.md
T
zuevav f4bca8449e main
2026-05-20 19:33:02 +03:00

358 lines
14 KiB
Markdown
Raw 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.
# Безопасность ZBrain
## Модель угроз
**Что защищаем:**
- Содержимое брейнов (особенно TeleraPharma — есть NDA и чувствительная инфраструктурная информация)
- Учётные записи пользователей
- API ключи (OpenAI, Anthropic) — финансовые потери при утечке
- MCP токены — могут давать доступ к чтению/записи брейнов
**От кого защищаем:**
- **Внешний атакующий** через публичный endpoint brain.zetit.ru
- **Скомпрометированный сервер клиента** с украденным MCP токеном
- **Случайная утечка** через копирование токена в git, log файлы, скриншот
- **Внутренний пользователь** с правами viewer, который хочет получить admin
**От чего не защищаемся (нет ресурсов):**
- Физический доступ к VM (предполагаем что гипервизор/датацентр доверенный)
- Скомпрометированный root на самой VM
- 0-day в Postgres, Node.js, библиотеках
## Роли пользователей
| Роль | Управление пользователями | Управление брейнами | Управление источниками | Управление токенами | Просмотр audit | UI |
|------|---------------------------|---------------------|------------------------|--------------------|-----|---|
| **owner** | ✓ Полное | ✓ Полное | ✓ Полное | ✓ Полное | ✓ Все | ✓ |
| **admin** | Только viewer/editor | ✓ Полное | ✓ Полное | ✓ Свои + смотреть чужие | ✓ Все | ✓ |
| **editor** | ✗ | ✗ Создавать нельзя; редактировать содержимое — да | ✓ В назначенных брейнах | ✓ Свои с scope :read и :write | ✓ Свои действия | ✓ |
| **viewer** | ✗ | ✗ | ✗ | ✓ Свои только :read | ✓ Свои действия | ✓ Read-only |
| **agent** (не человек) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ MCP only |
В первой версии у нас один owner — это ты. Остальные роли — для будущего.
## MCP Токены
### Формат
```
brain_pat_<random40chars>
```
Префикс `brain_pat_` (Personal Access Token) — чтобы легко grep'ать в логах и автоматических сканерах секретов.
### Хранение
- Plain text токен **никогда** не хранится
- Хранится `token_hash = SHA-256(token)` и `token_prefix = первые 8 символов после brain_pat_` для UI
- При создании токена UI показывает full token **один раз**, потом только prefix
### Scope формат
```
mcp:<level>:<brain-name>
```
Уровни:
- `mcp:read:<brain>` — query, search, get_page, get_tags, get_links, get_backlinks, get_timeline, get_stats, get_health, get_versions
- `mcp:write:<brain>` — всё из read + put_page, add_tag, remove_tag, add_link, remove_link, add_timeline_entry, sync_brain
- `mcp:admin:<brain>` — всё из write + delete_page, revert_version
Wildcards:
- `mcp:read:*` — read доступ ко всем брейнам (только owner)
- `mcp:*:zetit` — все права на zetit брейн
Один токен может иметь несколько scope, например:
```json
{
"scopes": ["mcp:read:zetit", "mcp:write:personal"]
}
```
### IP Allowlist
Опционально для каждого токена — список IP/CIDR откуда разрешён доступ.
Для production токенов (на серверах клиентов) — **обязательно** указывать IP клиентского сервера.
### TTL
- По умолчанию: 90 дней
- Опции в UI: 7 дней, 30 дней, 90 дней, 1 год, без срока
- "Без срока" доступен только owner и admin
### Revocation
Soft delete: `revoked_at = now()`.
Кэш в brainhub имеет TTL 30 секунд, поэтому отзыв вступает в силу в течение 30 секунд.
Для немедленного отзыва — `POST /api/admin/cache/invalidate-token`.
### Алгоритм валидации
```typescript
async function validateToken(token: string, brain: string, requiredScope: 'read'|'write'|'admin'): Promise<TokenInfo> {
// 1. Проверка формата
if (!token.startsWith('brain_pat_')) throw new Error('Invalid token format');
const tokenHash = sha256(token);
// 2. Проверка в кэше
const cached = tokenCache.get(tokenHash);
if (cached) {
if (cached.error) throw new Error(cached.error);
return validateScope(cached, brain, requiredScope);
}
// 3. DB lookup
const dbToken = await db.tokens.findByHash(tokenHash);
if (!dbToken) {
tokenCache.set(tokenHash, {error: 'Not found'}, 30_000);
throw new Error('Token not found');
}
// 4. Проверки статуса
if (dbToken.revoked_at) throw new Error('Token revoked');
if (dbToken.expires_at && dbToken.expires_at < new Date()) throw new Error('Token expired');
// 5. IP allowlist
if (dbToken.ip_allowlist?.length > 0) {
const clientIp = getClientIp();
if (!isIpInAllowlist(clientIp, dbToken.ip_allowlist)) {
throw new Error('IP not allowed');
}
}
// 6. Scope check
validateScope(dbToken, brain, requiredScope);
// 7. Кэшируем positive result
tokenCache.set(tokenHash, dbToken, 30_000);
// 8. Throttled last_used update
scheduleLastUsedUpdate(dbToken.id);
return dbToken;
}
```
## Сессии
### Cookie
```
Name: zbrain_session
Value: <jwt-signed session id>
HttpOnly: true
Secure: true (только в production)
SameSite: Lax
Path: /
Domain: brain.zetit.local или brain.zetit.ru
```
JWT внутри cookie - только session id, не данные пользователя. Все данные подтягиваются из БД по session id.
### Refresh tokens
- Access JWT: 15 минут
- Refresh token: 30 дней, ротируется при каждом использовании
- Refresh token хранится в БД как hash (см. таблицу `sessions`)
### Logout
- Удаление cookie
- `sessions.revoked_at = now()` для всех сессий пользователя (если "logout everywhere")
## OAuth
### Поддерживаемые провайдеры
1. **Yandex** (основной)
2. **GitHub** (резервный)
3. **Local email/password** (всегда доступен для owner)
### Привязка к существующему пользователю
Если OAuth возвращает email, который уже есть в users:
- Если у пользователя нет привязки OAuth → требуется подтверждение пароля
- Если есть привязка к другому провайдеру → ошибка ("Already linked to GitHub")
- Если есть привязка к этому же провайдеру → login успешный
### Новые пользователи через OAuth
По умолчанию **отключено** (флаг `OAUTH_AUTO_REGISTER=false`).
В первой версии — только invite-based: owner создаёт пользователя с email, тот логинится через OAuth, происходит match по email.
## 2FA (TOTP)
Опционально, в Sprint 7.
- Секрет генерируется при включении, показывается QR-код для Google Authenticator / Authy
- Сохраняется в `users.totp_secret` зашифрованным
- 8 backup codes генерируются и показываются один раз
- При логине после успешного password check → требуется TOTP код
- Для OAuth-логина 2FA не запрашивается (полагаемся на 2FA провайдера)
## Audit Log
### Что пишем
Категории событий:
**Auth:**
- `user.login.success` / `user.login.failure`
- `user.logout`
- `user.oauth.link` / `user.oauth.unlink`
- `user.totp.enable` / `user.totp.disable`
**User management:**
- `user.create`
- `user.update`
- `user.delete`
- `user.role.change`
**Brain management:**
- `brain.create`
- `brain.update`
- `brain.delete`
- `brain.sync.start` / `brain.sync.complete` / `brain.sync.fail`
**Source management:**
- `source.add`
- `source.update`
- `source.delete`
**Token management:**
- `token.create` (с указанием scopes)
- `token.revoke`
**MCP usage:**
- `mcp.call` (rate-sampled: 1 запись на 10 вызовов на токен в минуту)
**Admin actions:**
- `admin.cache.invalidate`
- `admin.config.update`
### Формат
```typescript
{
id: bigint, // serial
actor_type: 'user' | 'token',
actor_id: uuid, // user.id или token.id
action: string, // 'brain.create', 'mcp.call', etc.
resource_type: string?, // 'brain', 'token', 'source', etc.
resource_id: string?, // ID объекта
payload: jsonb, // дополнительные данные
ip: inet?,
user_agent: text?,
created_at: timestamptz
}
```
### Хранение
- Хранятся **навсегда** в первой версии (для compliance)
- В будущем — архив старее года в отдельную холодную БД
- Индексы: `(actor_type, actor_id, created_at DESC)`, `(resource_type, resource_id, created_at DESC)`
### Batch insert
MCP-запросов будет много (тысячи в день). Не делаем INSERT на каждый запрос:
```typescript
const auditQueue: AuditEntry[] = [];
// API endpoint:
function logAudit(entry: AuditEntry) {
auditQueue.push(entry);
// не ждём insert
}
// Worker раз в секунду:
setInterval(async () => {
if (auditQueue.length === 0) return;
const batch = auditQueue.splice(0, 1000);
await db.audit_log.insertMany(batch);
}, 1000);
// graceful shutdown:
process.on('SIGTERM', async () => {
if (auditQueue.length > 0) {
await db.audit_log.insertMany(auditQueue);
}
});
```
## Сетевая безопасность
### Внутренний контур (brain.zetit.local)
- Доступ только из сети ZETIT или через VPN
- Plain HTTP допустим (доверенная сеть)
- Postgres слушает только localhost (127.0.0.1)
- UFW: открыты только 22 (SSH), 80, 443
### Публичный контур (brain.zetit.ru)
- Только HTTPS, TLS 1.2+ с safe ciphers
- Rate limiting на nginx уровне:
- 30 r/s per IP (защита от подбора токенов)
- 100 r/s per token (защита от runaway script)
- Доступны только `/mcp/*`, `/oauth/*`, `/health`
- Любой другой path → 404 без подсказок
### SSH
- Только ключи, password auth отключён
- `PermitRootLogin no`
- Fail2ban на /var/log/auth.log
## Защита секретов
### В коде
- НИКОГДА не коммитим `.env`, `*.key`, `*.pem`
- `.gitignore` строгий
- Pre-commit hook с git-secrets или talisman (опционально)
### На VM
- `/etc/zbrain/.env` — права 600, owner zbrain
- Postgres пароли каждого брейна — в `/var/lib/zbrain/brains/<name>/config.json` (600, owner zbrain)
- API ключи OpenAI/Anthropic — в .env, не в коде
- TLS приватные ключи в `/etc/letsencrypt/live/` — стандартные права Let's Encrypt
### В БД
- Пароли пользователей — bcrypt с cost=12
- MCP токены — SHA-256 hash (HMAC не нужен, потому что коллизии не критичны)
- TOTP secrets — AES-128-GCM с ключом из TOKEN_ENCRYPTION_KEY
- OAuth subjects — открытым текстом (это не секрет)
### Backup
- Полный бэкап шифруется age (или GPG) перед сохранением
- Публичный ключ для шифрования — на VM в /etc/zbrain/backup.age.pub
- Приватный ключ для расшифровки — **НЕ на VM**, хранится отдельно (KeePass / safe / другая машина)
## Чек-лист безопасности перед production
- [ ] Все пароли Postgres сильные (≥24 символа, generated)
- [ ] `/etc/zbrain/.env` с правами 600
- [ ] UFW активен, открыты только нужные порты
- [ ] Fail2ban настроен для SSH и nginx
- [ ] TLS сертификат валиден (A+ rating на ssllabs.com)
- [ ] Postgres listen_addresses = 'localhost'
- [ ] Создан age ключ для бэкапов, приватная часть НЕ на этой VM
- [ ] Скрипт backup.sh в cron, протестирован restore
- [ ] OAuth приложения зарегистрированы, callback URL'ы правильные
- [ ] Owner аккаунт создан, password сильный, 2FA включена (после Sprint 7)
- [ ] INITIAL_OWNER_PASSWORD сменён после первого логина
- [ ] Audit log пишется и читается через UI
- [ ] Test: попытка доступа к /api/* через brain.zetit.ru → 404
- [ ] Test: запрос с revoked токеном → 401 в течение 30 секунд
- [ ] Test: запрос с неверным scope → 403