Звіт з аудиту безпеки — 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) Файли:
shared/crm-routes.ts:2613-2639(routeSseRequest)master-bot/api-server.ts:402-410(виклик)
Опис:
Функція routeSseRequest(pathname, query, registry) не отримує chatId і не викликає canAccessProject. Перевіряється лише:
- JWT через
crmAuthMiddleware 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 з ноутбука), але:
curl http://62.171.128.248:19210/api/internal/bridgesз самого VPS → HTTP 200 (loopback на public IP)- Будь-хто з контейнера на VPS може звертатись до
/api/internal/*БЕЗ auth - Одна помилкова команда
ufw allow 19210— миттєвий глобальний витік - Міграція на іншу 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 без валідації:
body.project_name— немаisValidProjectName(можна передати../evilабо порожній)- Нема перевірки, що проєкт існує
- Нема owner_id (але це задумано як internal — залежить від S1)
Рекомендований патч:
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, не на диск), але:
- додати
location ~ ^/(state|scripts|config|data)/ { deny all; }для defence-in-depth.
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)
- C1:
routeSseRequest— додатиcanAccessProject - C2:
/ws/terminal/:name— додатиcanAccessProject+ admin-only дляinteractive - C3:
/api/cli/*+/api/mcp/*— додатиcanAccessProjectгейт на рівні блоку
Цього тижня
- S1:
Bun.serve({ hostname: "127.0.0.1" }) - S2:
isValidProjectNameу/api/internal/chat/save
Backlog
- M1:
timingSafeEqualуverifyToken - M2: Nginx: додати
/config/,/data/до deny list - M3: Ревізія всіх endpoints на предмет
safePath
📎 Додаткові команди для регресійних тестів
# 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. |
Оцінка якості
- 8/8 патчів коректні, без регресій
- Defense-in-depth у M3 (regex + safePath)
- Правильний length-check ДО
timingSafeEqualу M1 - C2 interactive: CEO АБО роль admin (двійкова роль) — правильно
- Експорт helper'ів (
extractChatId,canAccessProject) — без дублювання логіки - Розробник знайшов V0, який Sentinel пропустив
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 — роботу прийнято.