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:

Descrição:
A função routeSseRequest(pathname, query, registry) não recebe chatId e não chama canAccessProject. Apenas é verificado:

  1. JWT via crmAuthMiddleware
  2. 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:

  1. curl http://62.171.128.248:19210/api/internal/bridges do próprio VPS → HTTP 200 (loopback no IP público)
  2. Qualquer pessoa em um container no VPS pode acessar /api/internal/* SEM autenticação
  3. Um único ufw allow 19210 equivocado — vazamento global imediato
  4. Migração para outro VPS sem UFW — vazamento imediato

/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/toolTODOS 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:

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:

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)

Esta semana

Backlog


📎 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

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.