Отчёт об аудите безопасности — Arc OS

Дата: 2026-04-23 Аудитор: Sentinel (Super DevOps / Security Auditor) Фаза проекта: 40.18 Область: Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-tenancy


Executive Summary

Проведён аудит безопасности системы Arc OS. Найдено 3 критические уязвимости multi-tenancy, позволяющие аутентифицированному пользователю получить доступ к ресурсам чужих проектов (логи, терминал, вики, задачи). На данный момент проект не хранит конфиденциальные данные множества клиентов, поэтому эксплуатационный риск низкий, но архитектурно система не готова к multi-user production.

Вердикт: YELLOW — немедленно патчить C1-C3 до приглашения реальных пользователей помимо CEO.


🔴 КРИТИЧЕСКИЕ УЯЗВИМОСТИ (3)

CVE-C1 — SSE-стримы без проверки owner_id

Severity: High CWE: CWE-285 (Improper Authorization) Файлы:

Описание: Функция routeSseRequest(pathname, query, registry) не получает chatId и не вызывает canAccessProject. Проверяется только:

  1. JWT через crmAuthMiddleware
  2. isValidProjectName — только чтобы не было path traversal

Эксплуатация:

# Пользователь A получил свой токен через /api/auth/login
# Он знает, что существует проект "victim-project" (из БД или угадыванием)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → получает стрим JSONL-логов чужого проекта в реальном времени
# То же самое для /api/sse/consultant/:name

Рекомендуемый патч:

// shared/crm-routes.ts:2613
export function routeSseRequest(
  pathname: string,
  query: URLSearchParams,
  registry: Registry,
  chatId: string | null,  // ← добавить
): Response | null {
  const logsMatch = pathname.match(/^\/api\/sse\/logs\/([^/]+)$/);
  if (logsMatch) {
    const name = decodeURIComponent(logsMatch[1]);
    if (!isValidProjectName(name)) { ... }
    if (!canAccessProject(registry, chatId, name)) {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }
    return handleSseLogs(name, registry, query);
  }
  // то же для /api/sse/consultant/:name
}

// master-bot/api-server.ts:406
const chatId = extractChatId(req);  // экспортировать из crm-routes
const sseResponse = routeSseRequest(url.pathname, url.searchParams, ctx.registry, chatId);

CVE-C2 — WebSocket терминал без owner_id

Severity: Critical CWE: CWE-285 Файл: master-bot/api-server.ts:250-283

Описание: /ws/terminal/:name проверяет JWT + isValidProjectName, но не проверяет владение. Любой аутентифицированный пользователь получает доступ к tmux-сессии чужого проекта. В режиме ?mode=interactive это интерактивный shell.

Комментарий на :270 уже признаёт это:

const interactive = url.searchParams.get("mode") === "interactive";
// TODO: [debt-7] Gate behind admin-scoped token, not just query param

Эксплуатация:

ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ интерактивная shell-сессия в tmux чужого проекта
→ полное чтение/запись в файловой системе VPS от имени root

Рекомендуемый патч:

// master-bot/api-server.ts:258
const projectName = decodeURIComponent(wsMatch[1]);
if (!isValidProjectName(projectName)) { ... }

// ДОБАВИТЬ:
const chatId = verifyToken(token).chatId;
if (!canAccessProject(ctx.registry, chatId, projectName)) {
  return Response.json({ error: "Forbidden" }, { status: 403 });
}

// Интерактивный режим — только для admin
if (interactive) {
  const user = userQueries.findById(chatId);
  const isCeo = String(ctx.registry.master.ceo_chat_id) === chatId;
  if (!isCeo && user?.role !== "admin") {
    return Response.json({ error: "Admin required" }, { status: 403 });
  }
}

CVE-C3 — /api/cli/* + /api/mcp/* без owner_id (12+ эндпоинтов)

Severity: High CWE: CWE-285 Файл: master-bot/api-server.ts:913-1084

Описание: Весь блок /api/cli/* и /api/mcp/* проверяет только JWT (crmAuthMiddleware) + isValidProjectName. Никакого canAccessProject. Затронутые эндпоинты:

Эндпоинт Метод Последствие
/api/cli/init/:project/:mode GET Получение CLAUDE.md чужого проекта
/api/cli/chat-log/:project POST Вставка сообщений в чужой чат
/api/mcp/skills/:project POST/GET Изменение скиллов чужого проекта
/api/mcp/report/:project POST Отправка отчёта от имени чужого
/api/mcp/learnings/:project GET Чтение learnings.md чужого
/api/mcp/issues/:project POST/GET CRUD задач чужого проекта
/api/mcp/issues/:project/:id PUT Изменение чужих задач
/api/mcp/issues/:project/:id/log POST Запись в activity trail
/api/mcp/wiki/:project PUT Перезапись вики чужого проекта
/api/mcp/roadmap/:project GET/PUT Изменение roadmap

Рекомендуемый патч:

// master-bot/api-server.ts:914 (сразу после if блока)
if (url.pathname.startsWith("/api/cli/") || url.pathname.startsWith("/api/mcp/")) {
  const preflight = handleCorsPreflightIfNeeded(req);
  if (preflight) return preflight;
  const denied = crmAuthMiddleware(req);
  if (denied) { ... }

  // ДОБАВИТЬ ЭТОТ БЛОК:
  const projectMatch = url.pathname.match(/^\/api\/(cli|mcp)\/[^/]+\/([^/]+)/);
  if (projectMatch) {
    const project = decodeURIComponent(projectMatch[2]);
    if (isValidProjectName(project)) {
      const chatId = extractChatId(req);
      if (!canAccessProject(ctx.registry, chatId, project)) {
        const headers = corsHeaders(req.headers.get("Origin") || undefined);
        return Response.json({ error: "Forbidden" }, { status: 403, headers });
      }
    }
  }

  // ... существующий роутер ...
}

⚠️ Важно: маршруты download (/api/cli/download/...) и device-code (/api/cli/device/*) НЕ ИМЕЮТ project в URL — они не должны блокироваться. Проверить порядок условий.


🟡 СЕРЬЁЗНЫЕ УЯЗВИМОСТИ (3)

CVE-S1 — Bun.serve слушает 0.0.0.0:19210

Severity: Medium (defense-in-depth) CWE: CWE-668 (Exposure of Resource to Wrong Sphere) Файл: master-bot/api-server.ts:159-161

Описание:

const server = Bun.serve({
  port: ctx.config.HEALTH_PORT,  // hostname отсутствует → default 0.0.0.0
  ...
});

На VPS ss -tlnp показывает:

LISTEN *:19210  users:(("bun",pid=578729,fd=15))

Bun слушает на всех интерфейсах. Сейчас UFW блокирует :19210 снаружи (HTTP 000 timeout с ноутбука), но:

  1. curl http://62.171.128.248:19210/api/internal/bridges с самого VPS → HTTP 200 (loopback на public IP)
  2. Любой из контейнера на VPS может обращаться к /api/internal/* БЕЗ auth
  3. Одна ошибочная команда ufw allow 19210 — мгновенная глобальная утечка
  4. Миграция на другой VPS без UFW — мгновенная утечка

/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool ВСЕ no-auth (комментарий: "localhost-only, not exposed via nginx").

Рекомендуемый патч:

const server = Bun.serve({
  hostname: "127.0.0.1",  // ← добавить
  port: ctx.config.HEALTH_PORT,
  ...
});

⚠️ Nginx на VPS проксирует 127.0.0.1:19210 — патч не сломает публичный трафик.


CVE-S2 — /api/internal/chat/save принимает произвольный project_name

Severity: Medium CWE: CWE-20 (Improper Input Validation) Файл: master-bot/api-server.ts:376-398

Описание: Эндпоинт вставляет в chat_messages без валидации:

Рекомендуемый патч:

if (!body.project_name || !isValidProjectName(body.project_name)) {
  return Response.json({ error: "Invalid project_name" }, { status: 400 });
}

CVE-S3 — interactive режим WebSocket через query-param

Severity: Medium Файл: master-bot/api-server.ts:270

Описание: ?mode=interactive открывает интерактивный shell для любого валидного токена, не admin-scoped. Автор кода сам пометил TODO: debt-7. Смотри патч в CVE-C2.


🟢 МИНОРНЫЕ НАХОДКИ

M1 — verifyToken: простое string-сравнение signature

Файл: shared/auth.ts:270

if (signature !== expected) { return { valid: false, ...}; }

Лучше: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 256-bit — практически безопасно, но best practice.

M2 — Nginx: /config/ не заблокирован

Файл: infra/nginx/citadel-crm.conf:179-180, 355-360

location ~ /\. { deny all; }
location ~ ^/(state|scripts)/ { deny all; }

CLAUDE.md говорит: "blocked paths (/.*, /config/, /state/)" — документация расходится с реальностью. Сейчас некритично (трафик / идёт в Docker, не на диск), но:

M3 — safePath() TODO

Файл: shared/crm-routes.ts:279

TODO: [debt-7] Apply to all endpoints, not just /files

Найдено самим проектом. Ревизия: проверить, какие ещё handler'ы принимают пользовательские пути и не вызывают safePath.


✅ ЧТО РАБОТАЕТ ОТЛИЧНО

Проверка Статус Доказательство
Vault не в git git check-ignore config/vault.json → matched .gitignore:30
vault-key не в git .gitignore:31 + chmod 600
data/citadel.db не в git .gitignore:35
.env не в git .gitignore:16
NotebookLM bridge только localhost --host 127.0.0.1 в systemd unit
NotebookLM не проксируется Nginx Нет location /notebook* в конфиге
Regex isValidProjectName /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, макс. 64 символа
canAccessProject для /api/crm/projects/:name/* Гейт на точке входа (:5689-5695)
handleGetProjects multi-tenancy Фильтр по owner_id (DB SSOT)
AES-256-GCM vault createCipheriv("aes-256-gcm"...)
Атомарные writes tmp.${pid} + mv в writeVaultFile
JWT TTL 24ч TOKEN_TTL_SEC = 24 * 60 * 60
OAuth CSRF state TTL 10 мин, одноразовый (auth.ts:51-71)
Сброс пароля TTL 30 мин RESET_TTL_MS
Верификация email TTL 24ч VERIFY_TTL_MS
Device code TTL 10 мин DEVICE_CODE_TTL_MS
vps-sync.sh owner_id backup Бэкап перед git pull, восстановление после
vps-sync.sh health smoke test Ожидать 401 на no-auth CRM
vps-sync.sh path traversal test Ожидать 401/403 на .hidden-traversal
UFW блокирует :19210/:19213/:19200 Тест с ноутбука: HTTP 000 timeout
CORS allowlist CRM_ALLOWED_ORIGINS env
CORS headers на error responses Lesson-learned в CLAUDE.md
WAL mode SQLite PRAGMA journal_mode = WAL
safePath() для /files endpoints resolve + startsWith + null при нарушении
Защита от path traversal при загрузке Path traversal blocked → 403

📋 План патчей (в порядке приоритета)

Сегодня (блокер для multi-user)

На этой неделе

Бэклог


📎 Дополнительные команды для регрессионных тестов

# Smoke test: SSE должен возвращать 403 для чужого проекта
TOKEN_A=$(curl -s -X POST https://arc-os.co/api/auth/login -d '{"email":"a@test","password":"..."}' | jq -r .token)
curl -N "https://arc-os.co/api/sse/logs/b-project?token=$TOKEN_A"
# Ожидается: 403 Forbidden (сейчас: 200 OK — VULN)

# Smoke test: WebSocket терминал чужого проекта
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Ожидается: 403 (сейчас: upgrade OK — VULN)

# Smoke test: MCP wiki update чужого проекта
curl -X PUT "https://arc-os.co/api/mcp/wiki/b-project" \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"file":"README","content":"pwned"}'
# Ожидается: 403 (сейчас: 200 OK — VULN)

# Проверить привязку порта после патча CVE-S1:
ssh VPS "ss -tlnp | grep 19210"
# Ожидается: 127.0.0.1:19210 (сейчас: *:19210)

🔄 Повторный аудит (2026-04-23, post-Phase 42)

Закрыто: 11/11 находок + 1 bonus (V0: handleSaveSkill path-traversal — нашёл разработчик).

Верификация патчей

CVE Файл:Строка Статус
C1 crm-routes.ts:2617, 2627, 2641
C2 api-server.ts:274-288 (guard + CEO/admin interactive)
C3 api-server.ts:950-968 (entry-gate regex + skipGuard)
S1 api-server.ts:161 + prod ss -tlnp: 127.0.0.1:19210
S2 api-server.ts:404
M1 auth.ts:272 (timingSafeEqual + length-check до compare)
M2 citadel-crm.conf:180, 360 (deny-list расширен)
M3 crm-routes.ts:4097, 4107, 4115 (regex + safePath belt+suspenders)
V0 (bonus) crm-routes.ts:4097

Prod smoke-тесты

ss -tlnp | grep 19210      # → 127.0.0.1:19210 (было *:19210) ✅
curl 62.171.128.248:19210/api/internal/bridges --max-time 3  # → 000 timeout ✅
curl localhost:18888/api/crm/projects             # → 401 ✅

Новые находки повторного аудита

ID Severity Файл:Строка Описание
FN-1 Medium api-server.ts:956-967 C3 entry-gate имеет fail-open паттерн — если isValidProjectName === false, guard пропускается. Сейчас безопасно (handler'ы проверяют сами), но fail-closed архитектурно сильнее.
FN-2 Low api-server.ts:955 Dead reference на /api/cli/chat-save в skipGuard — эндпоинт не существует. Гигиена.
FN-3 Low api-server.ts:376-391 /api/internal/bridge-event/:project без isValidProjectName. Защищено S1 + UFW, но belt+suspenders.

Оценка качества

Karpathy compliance: 8.5/10

Follow-up задачи

ID Приоритет
SEC-FN1 P2 — fail-closed в C3 entry-gate
SEC-FN2 P3 — удалить dead ref /api/cli/chat-save
SEC-V1 P2 — isValidProjectName + assertLocalhost guard в /api/internal/bridge-event/:project
SEC-V2 P2 — ревизия handleCliInit на env leak через template substitution
SEC-V3 P3 — path-traversal regression test в vps-sync.sh

🔐 Подпись

Sentinel Standard: Karpathy — хирургическая точность, минимализм, беспощадная критичность.

Статус: CLOSED — первичный аудит + повторный аудит завершены. Вердикт повторного аудита: 🟢 GREEN — работа принята.