Звіт з аудиту безпеки — 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, що дозволяють автентифікованому користувачу отримати доступ до ресурсів чужих проєктів (логи, термінал, wiki, задачі). Наразі проєкт не зберігає конфіденційні дані багатьох клієнтів, тому експлуатаційний ризик низький, але архітектурно система не готова до 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" (з DB або guessing)
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 });
}

// Interactive mode — admin-only
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+ endpoints)

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

Опис: Весь блок /api/cli/* та /api/mcp/* перевіряє лише JWT (crmAuthMiddleware) + isValidProjectName. Жодного canAccessProject. Зачеплені endpoints:

Endpoint Method Наслідок
/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 Перезапис wiki чужого проєкту
/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

Опис: Endpoint вставляє в 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-only --host 127.0.0.1 у systemd unit
NotebookLM не проксується Nginx Немає location /notebook* у конфігу
isValidProjectName regex /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, max 64 символи
canAccessProject для /api/crm/projects/:name/* Gate at entry point (:5689-5695)
handleGetProjects multi-tenancy Фільтрація за owner_id (DB SSOT)
AES-256-GCM vault createCipheriv("aes-256-gcm"...)
Атомарні writes tmp.${pid} + mv у writeVaultFile
JWT 24h TTL TOKEN_TTL_SEC = 24 * 60 * 60
OAuth CSRF state 10min TTL, одноразове використання (auth.ts:51-71)
Password reset 30min TTL RESET_TTL_MS
Email verification 24h TTL VERIFY_TTL_MS
Device code 10min TTL DEVICE_CODE_TTL_MS
vps-sync.sh owner_id backup Backup перед git pull, restore після
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 env CRM_ALLOWED_ORIGINS
CORS headers на error responses Lesson-learned у CLAUDE.md
WAL mode SQLite PRAGMA journal_mode = WAL
safePath() для /files endpoints resolve + startsWith + null при порушенні
Path traversal guard при завантаженні Path traversal blocked → 403

📋 План патчів (за пріоритетом)

Сьогодні (blocker для multi-user)

Цього тижня

Backlog


📎 Додаткові команди для регресійних тестів

# 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"
# Expected: 403 Forbidden (currently: 200 OK — VULN)

# Smoke test: WebSocket terminal чужого проекту
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Expected: 403 (currently: 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"}'
# Expected: 403 (currently: 200 OK — VULN)

# Verify port bind after CVE-S1 patch:
ssh VPS "ss -tlnp | grep 19210"
# Expected: 127.0.0.1:19210 (currently: *:19210)

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

Закрито: 11/11 знахідок + 1 бонус (V0: path-traversal у handleSaveSkill — знайшов розробник).

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

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 (бонус) crm-routes.ts:4097

Прод 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 пропускається. Наразі безпечно (handlers перевіряють самі), але fail-closed архітектурно сильніший.
FN-2 Low api-server.ts:955 Мертве посилання на /api/cli/chat-save у skipGuard — endpoint не існує. Гігієна.
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 — первинний аудит + повторний аудит завершено. Вердикт re-audit: 🟢 GREEN — роботу прийнято.