Relatório de Auditoria de Segurança — Arc OS
Data: 2026-04-23
Auditor: Sentinel (Super DevOps / Security Auditor)
Phase do projeto: 40.18
Escopo: Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-tenancy
Executive Summary
Foi realizada uma auditoria de segurança do sistema Arc OS. Foram encontradas 3 vulnerabilidades críticas de multi-tenancy que permitem a um usuário autenticado acessar recursos de projetos alheios (logs, terminal, wiki, issues). Atualmente o projeto não armazena dados confidenciais de múltiplos clientes, portanto o risco de exploração é baixo, mas arquitetonicamente o sistema não está pronto para produção multi-usuário.
Veredicto: YELLOW — corrigir C1-C3 imediatamente antes de convidar usuários reais além do CEO.
🔴 VULNERABILIDADES CRÍTICAS (3)
CVE-C1 — Streams SSE sem verificação de owner_id
Severity: High
CWE: CWE-285 (Improper Authorization)
Arquivos:
shared/crm-routes.ts:2613-2639(routeSseRequest)master-bot/api-server.ts:402-410(chamada)
Descrição:
A função routeSseRequest(pathname, query, registry) não recebe chatId e não chama canAccessProject. Apenas é verificado:
- JWT via
crmAuthMiddleware isValidProjectName— somente para evitar path traversal
Exploração:
# Usuário A obteve seu token via /api/auth/login
# Ele sabe que existe um projeto "victim-project" (do DB ou por adivinhação)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → recebe stream de logs JSONL do projeto alheio em tempo real
# O mesmo vale para /api/sse/consultant/:name
Patch recomendado:
// shared/crm-routes.ts:2613
export function routeSseRequest(
pathname: string,
query: URLSearchParams,
registry: Registry,
chatId: string | null, // ← adicionar
): 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);
}
// o mesmo para /api/sse/consultant/:name
}
// master-bot/api-server.ts:406
const chatId = extractChatId(req); // exportar de crm-routes
const sseResponse = routeSseRequest(url.pathname, url.searchParams, ctx.registry, chatId);
CVE-C2 — Terminal WebSocket sem owner_id
Severity: Critical
CWE: CWE-285
Arquivo: master-bot/api-server.ts:250-283
Descrição:/ws/terminal/:name verifica JWT + isValidProjectName, mas não verifica a propriedade. Qualquer usuário autenticado obtém acesso à sessão tmux de um projeto alheio. No modo ?mode=interactive isso equivale a um shell interativo.
O comentário em :270 já admite:
const interactive = url.searchParams.get("mode") === "interactive";
// TODO: [debt-7] Gate behind admin-scoped token, not just query param
Exploração:
ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ sessão shell interativa no tmux do projeto alheio
→ leitura/escrita completa no sistema de arquivos do VPS como root
Patch recomendado:
// master-bot/api-server.ts:258
const projectName = decodeURIComponent(wsMatch[1]);
if (!isValidProjectName(projectName)) { ... }
// ADICIONAR:
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/* sem owner_id (12+ endpoints)
Severity: High
CWE: CWE-285
Arquivo: master-bot/api-server.ts:913-1084
Descrição:
Todo o bloco /api/cli/* e /api/mcp/* verifica apenas JWT (crmAuthMiddleware) + isValidProjectName. Sem nenhum canAccessProject. Endpoints afetados:
| Endpoint | Method | Consequência |
|---|---|---|
/api/cli/init/:project/:mode |
GET | Acesso ao CLAUDE.md do projeto alheio |
/api/cli/chat-log/:project |
POST | Inserção de mensagens no chat alheio |
/api/mcp/skills/:project |
POST/GET | Alteração de skills do projeto alheio |
/api/mcp/report/:project |
POST | Envio de relatório em nome de outro projeto |
/api/mcp/learnings/:project |
GET | Leitura do learnings.md alheio |
/api/mcp/issues/:project |
POST/GET | CRUD de issues do projeto alheio |
/api/mcp/issues/:project/:id |
PUT | Alteração de issues alheias |
/api/mcp/issues/:project/:id/log |
POST | Escrita na trilha de atividades |
/api/mcp/wiki/:project |
PUT | Sobrescrita da wiki do projeto alheio |
/api/mcp/roadmap/:project |
GET/PUT | Alteração do roadmap |
Patch recomendado:
// master-bot/api-server.ts:914 (logo após o bloco 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) { ... }
// ADICIONAR ESTE BLOCO:
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 });
}
}
}
// ... roteador existente ...
}
⚠️ Atenção: as rotas de download (/api/cli/download/...) e device-code (/api/cli/device/*) NÃO POSSUEM project na URL — elas não devem ser bloqueadas. Verificar a ordem das condições.
🟡 VULNERABILIDADES SÉRIAS (3)
CVE-S1 — Bun.serve escutando em 0.0.0.0:19210
Severity: Medium (defense-in-depth)
CWE: CWE-668 (Exposure of Resource to Wrong Sphere)
Arquivo: master-bot/api-server.ts:159-161
Descrição:
const server = Bun.serve({
port: ctx.config.HEALTH_PORT, // hostname ausente → padrão 0.0.0.0
...
});
No VPS, ss -tlnp mostra:
LISTEN *:19210 users:(("bun",pid=578729,fd=15))
O Bun escuta em todas as interfaces. Atualmente o UFW bloqueia :19210 externamente (timeout HTTP 000 do laptop), mas:
curl http://62.171.128.248:19210/api/internal/bridgesdo próprio VPS → HTTP 200 (loopback no IP público)- Qualquer pessoa em um container no VPS pode acessar
/api/internal/*SEM autenticação - Um único
ufw allow 19210equivocado — vazamento global imediato - Migração para outro VPS sem UFW — vazamento imediato
/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool — TODOS sem autenticação (comentário: "localhost-only, not exposed via nginx").
Patch recomendado:
const server = Bun.serve({
hostname: "127.0.0.1", // ← adicionar
port: ctx.config.HEALTH_PORT,
...
});
⚠️ O Nginx no VPS faz proxy para 127.0.0.1:19210 — o patch não quebra o tráfego público.
CVE-S2 — /api/internal/chat/save aceita project_name arbitrário
Severity: Medium
CWE: CWE-20 (Improper Input Validation)
Arquivo: master-bot/api-server.ts:376-398
Descrição:
O endpoint insere em chat_messages sem validação:
body.project_name— semisValidProjectName(possível passar../evilou vazio)- Sem verificação de que o projeto existe
- Sem owner_id (mas isso é proposital como internal — depende de S1)
Patch recomendado:
if (!body.project_name || !isValidProjectName(body.project_name)) {
return Response.json({ error: "Invalid project_name" }, { status: 400 });
}
CVE-S3 — Modo interactive do WebSocket via query-param
Severity: Medium
Arquivo: master-bot/api-server.ts:270
Descrição:?mode=interactive abre um shell interativo para qualquer token válido, não apenas tokens com escopo de admin. O próprio autor do código marcou TODO: debt-7. Veja o patch em CVE-C2.
🟢 DESCOBERTAS MENORES
M1 — verifyToken: comparação simples de string na assinatura
Arquivo: shared/auth.ts:270
if (signature !== expected) { return { valid: false, ...}; }
Melhor usar: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 de 256 bits — praticamente seguro, mas é uma boa prática.
M2 — Nginx: /config/ não está bloqueado
Arquivo: infra/nginx/citadel-crm.conf:179-180, 355-360
location ~ /\. { deny all; }
location ~ ^/(state|scripts)/ { deny all; }
O CLAUDE.md diz: "blocked paths (/.*, /config/, /state/)" — a documentação diverge da realidade. Atualmente não é crítico (o tráfego em / vai para o Docker, não para o disco), mas:
- Adicionar
location ~ ^/(state|scripts|config|data)/ { deny all; }para defense-in-depth.
M3 — safePath() TODO
Arquivo: shared/crm-routes.ts:279
TODO: [debt-7] Apply to all endpoints, not just /files
Identificado pelo próprio projeto. Revisão: verificar quais outros handlers recebem caminhos do usuário e não chamam safePath.
✅ O QUE FUNCIONA PERFEITAMENTE
| Verificação | Status | Evidência |
|---|---|---|
| Vault fora do git | ✅ | git check-ignore config/vault.json → matched .gitignore:30 |
| vault-key fora do git | ✅ | .gitignore:31 + chmod 600 |
data/citadel.db fora do git |
✅ | .gitignore:35 |
.env fora do git |
✅ | .gitignore:16 |
| NotebookLM bridge somente localhost | ✅ | --host 127.0.0.1 na unit do systemd |
| NotebookLM não proxiado pelo Nginx | ✅ | Sem location /notebook* na configuração |
Regex isValidProjectName |
✅ | /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, máx. 64 caracteres |
canAccessProject para /api/crm/projects/:name/* |
✅ | Gate no entry point (:5689-5695) |
Multi-tenancy em handleGetProjects |
✅ | Filtro por owner_id (DB SSOT) |
| Vault AES-256-GCM | ✅ | createCipheriv("aes-256-gcm"...) |
| Escritas atômicas | ✅ | tmp.${pid} + mv em writeVaultFile |
| JWT TTL de 24h | ✅ | TOKEN_TTL_SEC = 24 * 60 * 60 |
| CSRF state no OAuth | ✅ | TTL de 10min, uso único (auth.ts:51-71) |
| Redefinição de senha TTL de 30min | ✅ | RESET_TTL_MS |
| Verificação de email TTL de 24h | ✅ | VERIFY_TTL_MS |
| Device code TTL de 10min | ✅ | DEVICE_CODE_TTL_MS |
Backup de owner_id em vps-sync.sh |
✅ | Backup antes do git pull, restore depois |
Smoke test de health em vps-sync.sh |
✅ | Espera 401 no CRM sem autenticação |
Teste de path traversal em vps-sync.sh |
✅ | Espera 401/403 no .hidden-traversal |
UFW bloqueia :19210/:19213/:19200 |
✅ | Teste do laptop: timeout HTTP 000 |
| Allowlist CORS | ✅ | Env CRM_ALLOWED_ORIGINS |
| Headers CORS nas respostas de erro | ✅ | Lesson-learned no CLAUDE.md |
| Modo WAL no SQLite | ✅ | PRAGMA journal_mode = WAL |
safePath() para endpoints /files |
✅ | resolve + startsWith + null em violação |
| Guard contra path traversal no upload | ✅ | Path traversal blocked → 403 |
📋 Plano de Patches (por prioridade)
Hoje (bloqueadores para multi-usuário)
- C1:
routeSseRequest— adicionarcanAccessProject - C2:
/ws/terminal/:name— adicionarcanAccessProject+ somente admin parainteractive - C3:
/api/cli/*+/api/mcp/*— adicionarcanAccessProjectcomo entry-gate do bloco
Esta semana
- S1:
Bun.serve({ hostname: "127.0.0.1" }) - S2:
isValidProjectNameem/api/internal/chat/save
Backlog
- M1:
timingSafeEqualemverifyToken - M2: Nginx: adicionar
/config/,/data/à deny list - M3: Revisão de todos os endpoints quanto ao uso de
safePath
📎 Comandos Adicionais para Testes de Regressão
# Smoke test: SSE deve retornar 403 para projeto alheio
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: terminal WebSocket de projeto alheio
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Expected: 403 (currently: upgrade OK — VULN)
# Smoke test: atualização de wiki via MCP de projeto alheio
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)
# Verificar bind de porta após patch CVE-S1:
ssh VPS "ss -tlnp | grep 19210"
# Expected: 127.0.0.1:19210 (currently: *:19210)
🔄 Re-Auditoria (2026-04-23, pós-Phase 42)
Encerrado: 11/11 descobertas + 1 bônus (V0: path-traversal em handleSaveSkill — encontrado pelo desenvolvedor).
Verificação dos Patches
| CVE | Arquivo:Linha | Status |
|---|---|---|
| C1 | crm-routes.ts:2617, 2627, 2641 |
✅ |
| C2 | api-server.ts:274-288 (guard + CEO/admin interativo) |
✅ |
| 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 + verificação de comprimento antes da comparação) |
✅ |
| M2 | citadel-crm.conf:180, 360 (deny-list expandida) |
✅ |
| M3 | crm-routes.ts:4097, 4107, 4115 (regex + safePath belt+suspenders) |
✅ |
| V0 (bônus) | crm-routes.ts:4097 |
✅ |
Smoke tests em produção
ss -tlnp | grep 19210 # → 127.0.0.1:19210 (era *:19210) ✅
curl 62.171.128.248:19210/api/internal/bridges --max-time 3 # → 000 timeout ✅
curl localhost:18888/api/crm/projects # → 401 ✅
Novas Descobertas da Re-Auditoria
| ID | Severity | Arquivo:Linha | Descrição |
|---|---|---|---|
| FN-1 | Medium | api-server.ts:956-967 |
O entry-gate C3 tem padrão fail-open — se isValidProjectName === false, o guard é ignorado. Atualmente seguro (os handlers verificam por conta própria), mas fail-closed é arquitetonicamente mais robusto. |
| FN-2 | Low | api-server.ts:955 |
Referência morta a /api/cli/chat-save no skipGuard — endpoint não existe. Higiene de código. |
| FN-3 | Low | api-server.ts:376-391 |
/api/internal/bridge-event/:project sem isValidProjectName. Protegido por S1 + UFW, mas belt+suspenders. |
Avaliação de Qualidade
- 8/8 patches corretos, sem regressões
- Defense-in-depth no M3 (regex + safePath)
- Verificação de comprimento correta ANTES de
timingSafeEqualno M1 - Modo interativo C2: CEO OU role admin (papel binário) — correto
- Export dos helpers (
extractChatId,canAccessProject) — sem duplicação de lógica - Desenvolvedor encontrou o V0, que o Sentinel havia perdido
Karpathy compliance: 8.5/10
Tarefas de Follow-up
| ID | Prioridade |
|---|---|
| SEC-FN1 | P2 — fail-closed no entry-gate C3 |
| SEC-FN2 | P3 — remover referência morta /api/cli/chat-save |
| SEC-V1 | P2 — isValidProjectName + guard assertLocalhost em /api/internal/bridge-event/:project |
| SEC-V2 | P2 — revisão de handleCliInit quanto a vazamento de env via substituição de template |
| SEC-V3 | P3 — teste de regressão de path-traversal em vps-sync.sh |
🔐 Assinatura
Padrão Sentinel: Karpathy — precisão cirúrgica, minimalismo, criticidade implacável.
Status: CLOSED — auditoria inicial + re-auditoria concluídas.
Veredicto da re-auditoria: 🟢 GREEN — trabalho aprovado.