Отчёт об аудите безопасности — 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) Файлы:
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" (из БД или угадыванием)
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 с ноутбука), но:
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
Описание:
Эндпоинт вставляет в 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 | ✅ | --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)
- 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
Бэклог
- M1:
timingSafeEqualвverifyToken - M2: Nginx: добавить
/config/,/data/в deny list - M3: Ревизия всех эндпоинтов на предмет
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"
# Ожидается: 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. |
Оценка качества
- 8/8 патчей корректны, без регрессий
- Defense-in-depth в M3 (regex + safePath)
- Правильный length-check ДО
timingSafeEqualв M1 - C2 interactive: CEO OR admin role (бинарная роль) — правильно
- Экспорт хелперов (
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 — первичный аудит + повторный аудит завершены.
Вердикт повторного аудита: 🟢 GREEN — работа принята.