Rapport d'audit de sécurité — Arc OS

Date : 2026-04-23 Auditeur : Sentinel (Super DevOps / Security Auditor) Phase du projet : 40.18 Périmètre : Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-tenancy


Résumé exécutif

Un audit de sécurité du système Arc OS a été réalisé. 3 vulnérabilités critiques de multi-tenancy ont été trouvées, permettant à un utilisateur authentifié d'accéder aux ressources des projets d'autrui (logs, terminal, wiki, tickets). Actuellement le projet ne stocke pas de données confidentielles de nombreux clients, donc le risque d'exploitation est faible, mais architecturalement le système n'est pas prêt pour la production multi-utilisateur.

Verdict : JAUNE — patcher C1-C3 immédiatement avant d'inviter de vrais utilisateurs autres que le CEO.


🔴 VULNÉRABILITÉS CRITIQUES (3)

CVE-C1 — Flux SSE sans vérification de owner_id

Sévérité : Élevée CWE : CWE-285 (Autorisation inappropriée) Fichiers :

Description : La fonction routeSseRequest(pathname, query, registry) ne reçoit pas chatId et n'appelle pas canAccessProject. Seules ces vérifications sont faites :

  1. JWT via crmAuthMiddleware
  2. isValidProjectName — uniquement pour éviter le path traversal

Exploitation :

# L'utilisateur A a obtenu son token via /api/auth/login
# Il sait que le projet "victim-project" existe (depuis la DB ou par devinette)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → reçoit le flux JSONL des logs d'un projet étranger en temps réel
# Idem pour /api/sse/consultant/:name

Patch recommandé :

// shared/crm-routes.ts:2613
export function routeSseRequest(
  pathname: string,
  query: URLSearchParams,
  registry: Registry,
  chatId: string | null,  // ← ajouter
): 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);
  }
  // idem pour /api/sse/consultant/:name
}

// master-bot/api-server.ts:406
const chatId = extractChatId(req);  // exporter depuis crm-routes
const sseResponse = routeSseRequest(url.pathname, url.searchParams, ctx.registry, chatId);

CVE-C2 — Terminal WebSocket sans owner_id

Sévérité : Critique CWE : CWE-285 Fichier : master-bot/api-server.ts:250-283

Description : /ws/terminal/:name vérifie JWT + isValidProjectName, mais ne vérifie pas la propriété. N'importe quel utilisateur authentifié accède à la session tmux du projet d'autrui. En mode ?mode=interactive, c'est un shell interactif.

Le commentaire à :270 l'avoue déjà :

const interactive = url.searchParams.get("mode") === "interactive";
// TODO: [debt-7] Gate behind admin-scoped token, not just query param

Exploitation :

ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ session shell interactive dans le tmux du projet étranger
→ lecture/écriture complète du système de fichiers VPS en tant que root

Patch recommandé :

// master-bot/api-server.ts:258
const projectName = decodeURIComponent(wsMatch[1]);
if (!isValidProjectName(projectName)) { ... }

// AJOUTER :
const chatId = verifyToken(token).chatId;
if (!canAccessProject(ctx.registry, chatId, projectName)) {
  return Response.json({ error: "Forbidden" }, { status: 403 });
}

// Mode interactif — admin uniquement
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/* sans owner_id (12+ endpoints)

Sévérité : Élevée CWE : CWE-285 Fichier : master-bot/api-server.ts:913-1084

Description : Tout le bloc /api/cli/* et /api/mcp/* vérifie uniquement JWT (crmAuthMiddleware) + isValidProjectName. Aucun canAccessProject. Endpoints concernés :

Endpoint Méthode Conséquence
/api/cli/init/:project/:mode GET Obtenir CLAUDE.md du projet étranger
/api/cli/chat-log/:project POST Insérer des messages dans le chat étranger
/api/mcp/skills/:project POST/GET Modifier les skills du projet étranger
/api/mcp/report/:project POST Envoyer un rapport au nom d'un étranger
/api/mcp/learnings/:project GET Lire learnings.md d'un étranger
/api/mcp/issues/:project POST/GET CRUD tickets du projet étranger
/api/mcp/issues/:project/:id PUT Modifier des tickets étrangers
/api/mcp/issues/:project/:id/log POST Écrire dans le trail d'activité
/api/mcp/wiki/:project PUT Écraser le wiki du projet étranger
/api/mcp/roadmap/:project GET/PUT Modifier la roadmap

Patch recommandé :

// master-bot/api-server.ts:914 (juste après le bloc 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) { ... }

  // AJOUTER CE BLOC :
  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 });
      }
    }
  }

  // ... routeur existant ...
}

⚠️ Attention : les routes de téléchargement (/api/cli/download/...) et device-code (/api/cli/device/*) N'ONT PAS de projet dans l'URL — elles ne doivent pas être bloquées. Vérifier l'ordre des conditions.


🟡 VULNÉRABILITÉS SÉRIEUSES (3)

CVE-S1 — Bun.serve écoute sur 0.0.0.0:19210

Sévérité : Moyenne (défense en profondeur) CWE : CWE-668 (Exposition d'une ressource dans la mauvaise sphère) Fichier : master-bot/api-server.ts:159-161

Description :

const server = Bun.serve({
  port: ctx.config.HEALTH_PORT,  // hostname absent → défaut 0.0.0.0
  ...
});

Sur le VPS ss -tlnp montre :

LISTEN *:19210  users:(("bun",pid=578729,fd=15))

Bun écoute sur toutes les interfaces. Actuellement UFW bloque :19210 de l'extérieur (timeout HTTP 000 depuis le laptop), mais :

  1. curl http://62.171.128.248:19210/api/internal/bridges depuis le VPS lui-même → HTTP 200 (loopback sur IP publique)
  2. N'importe qui dans un conteneur du VPS peut accéder à /api/internal/* SANS auth
  3. Une seule commande erronée ufw allow 19210 — fuite globale immédiate
  4. Migration vers un autre VPS sans UFW — fuite immédiate

/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool sont tous sans auth (commentaire : "localhost-only, not exposed via nginx").

Patch recommandé :

const server = Bun.serve({
  hostname: "127.0.0.1",  // ← ajouter
  port: ctx.config.HEALTH_PORT,
  ...
});

⚠️ Nginx sur le VPS proxie 127.0.0.1:19210 — le patch ne cassera pas le trafic public.


CVE-S2 — /api/internal/chat/save accepte un project_name arbitraire

Sévérité : Moyenne CWE : CWE-20 (Validation inappropriée des entrées) Fichier : master-bot/api-server.ts:376-398

Description : L'endpoint insère dans chat_messages sans validation :

Patch recommandé :

if (!body.project_name || !isValidProjectName(body.project_name)) {
  return Response.json({ error: "Invalid project_name" }, { status: 400 });
}

CVE-S3 — Mode interactive WebSocket via query-param

Sévérité : Moyenne Fichier : master-bot/api-server.ts:270

Description : ?mode=interactive ouvre un shell interactif pour n'importe quel token valide, pas admin-scopé. L'auteur du code a lui-même noté TODO: debt-7. Voir le patch dans CVE-C2.


🟢 RÉSULTATS MINEURS

M1 — verifyToken : comparaison de signature par chaîne simple

Fichier : shared/auth.ts:270

if (signature !== expected) { return { valid: false, ...}; }

Mieux : crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 256-bit — pratiquement sûr, mais bonne pratique.

M2 — Nginx : /config/ non bloqué

Fichier : infra/nginx/citadel-crm.conf:179-180, 355-360

location ~ /\. { deny all; }
location ~ ^/(state|scripts)/ { deny all; }

CLAUDE.md dit : "blocked paths (/.*, /config/, /state/)" — la documentation diverge de la réalité. Pas critique actuellement (le trafic / va dans Docker, pas sur le disque), mais :

M3 — safePath() TODO

Fichier : shared/crm-routes.ts:279

TODO: [debt-7] Apply to all endpoints, not just /files

Trouvé par le projet lui-même. Révision : vérifier quels autres handlers acceptent des chemins utilisateur et n'appellent pas safePath.


✅ CE QUI FONCTIONNE PARFAITEMENT

Vérification Statut Preuve
Vault pas dans git git check-ignore config/vault.json → matched .gitignore:30
vault-key pas dans git .gitignore:31 + chmod 600
data/citadel.db pas dans git .gitignore:35
.env pas dans git .gitignore:16
Bridge NotebookLM localhost uniquement --host 127.0.0.1 dans l'unité systemd
NotebookLM non proxié par Nginx Pas de location /notebook* dans la config
Regex isValidProjectName /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, max 64 caractères
canAccessProject pour /api/crm/projects/:name/* Garde au point d'entrée (:5689-5695)
Multi-tenancy handleGetProjects Filtre par owner_id (DB SSOT)
Vault AES-256-GCM createCipheriv("aes-256-gcm"...)
Écritures atomiques tmp.${pid} + mv dans writeVaultFile
JWT TTL 24h TOKEN_TTL_SEC = 24 * 60 * 60
État CSRF OAuth TTL 10min, usage unique (auth.ts:51-71)
Réinitialisation mot de passe TTL 30min RESET_TTL_MS
Vérification email TTL 24h VERIFY_TTL_MS
Device code TTL 10min DEVICE_CODE_TTL_MS
Backup owner_id dans vps-sync.sh Backup avant git pull, restore après
Test smoke santé vps-sync.sh Attend 401 sur CRM sans auth
Test path traversal vps-sync.sh Attend 401/403 sur .hidden-traversal
UFW bloque :19210/:19213/:19200 Test depuis laptop : timeout HTTP 000
Allowlist CORS Env CRM_ALLOWED_ORIGINS
Headers CORS sur réponses d'erreur Leçon apprise dans CLAUDE.md
Mode WAL SQLite PRAGMA journal_mode = WAL
safePath() pour les endpoints /files resolve + startsWith + null en cas de violation
Garde path traversal au chargement Path traversal blocked → 403

Plan de patches (par priorité)

Aujourd'hui (bloquant pour multi-utilisateur)

Cette semaine

Backlog


Commandes pour les tests de régression

# Test smoke : SSE doit retourner 403 pour un projet étranger
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"
# Attendu : 403 Forbidden (actuellement : 200 OK — VULNÉRABLE)

# Test smoke : terminal WebSocket d'un projet étranger
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Attendu : 403 (actuellement : upgrade OK — VULNÉRABLE)

# Test smoke : mise à jour wiki MCP d'un projet étranger
curl -X PUT "https://arc-os.co/api/mcp/wiki/b-project" \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"file":"README","content":"pwned"}'
# Attendu : 403 (actuellement : 200 OK — VULNÉRABLE)

# Vérifier le bind de port après patch CVE-S1 :
ssh VPS "ss -tlnp | grep 19210"
# Attendu : 127.0.0.1:19210 (actuellement : *:19210)

Re-Audit (2026-04-23, post-Phase 42)

Fermé : 11/11 résultats + 1 bonus (V0 : path-traversal handleSaveSkill — trouvé par le développeur).

Vérification des patches

CVE Fichier:Ligne Statut
C1 crm-routes.ts:2617, 2627, 2641
C2 api-server.ts:274-288 (garde + interactif CEO/admin)
C3 api-server.ts:950-968 (regex porte d'entrée + skipGuard)
S1 api-server.ts:161 + prod ss -tlnp: 127.0.0.1:19210
S2 api-server.ts:404
M1 auth.ts:272 (timingSafeEqual + vérification longueur avant compare)
M2 citadel-crm.conf:180, 360 (deny-list étendu)
M3 crm-routes.ts:4097, 4107, 4115 (regex + safePath ceinture+bretelles)
V0 (bonus) crm-routes.ts:4097

Tests smoke production

ss -tlnp | grep 19210      # → 127.0.0.1:19210 (était *:19210) ✅
curl 62.171.128.248:19210/api/internal/bridges --max-time 3  # → 000 timeout ✅
curl localhost:18888/api/crm/projects             # → 401 ✅

Nouveaux résultats du re-audit

ID Sévérité Fichier:Ligne Description
FN-1 Moyen api-server.ts:956-967 La porte d'entrée C3 a un pattern fail-open — si isValidProjectName === false, la garde est skippée. Actuellement sûr (les handlers vérifient eux-mêmes), mais fail-closed est architecturalement plus fort.
FN-2 Faible api-server.ts:955 Référence morte à /api/cli/chat-save dans skipGuard — l'endpoint n'existe pas. Hygiène.
FN-3 Faible api-server.ts:376-391 /api/internal/bridge-event/:project sans isValidProjectName. Protégé par S1 + UFW, mais ceinture+bretelles.

Évaluation qualité

Conformité Karpathy : 8.5/10

Tâches de suivi

ID Priorité
SEC-FN1 P2 — fail-closed dans la porte d'entrée C3
SEC-FN2 P3 — supprimer la référence morte /api/cli/chat-save
SEC-V1 P2 — isValidProjectName + garde assertLocalhost dans /api/internal/bridge-event/:project
SEC-V2 P2 — révision de handleCliInit pour fuite env via substitution de template
SEC-V3 P3 — test de régression path-traversal dans vps-sync.sh

Signature

Standard Sentinel : Karpathy — précision chirurgicale, minimalisme, critique sans pitié.

Statut : FERMÉ — audit initial + re-audit terminés. Verdict du re-audit : 🟢 VERT — travail accepté.