Files
zuevav f4bca8449e main
2026-05-20 19:33:02 +03:00

14 KiB
Raw Permalink Blame History

Безопасность 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, например:

{
  "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.

Алгоритм валидации

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;
}

Сессии

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

Формат

{
  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 на каждый запрос:

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