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 :
shared/crm-routes.ts:2613-2639(routeSseRequest)master-bot/api-server.ts:402-410(appel)
Description :
La fonction routeSseRequest(pathname, query, registry) ne reçoit pas chatId et n'appelle pas canAccessProject. Seules ces vérifications sont faites :
- JWT via
crmAuthMiddleware 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 :
curl http://62.171.128.248:19210/api/internal/bridgesdepuis le VPS lui-même → HTTP 200 (loopback sur IP publique)- N'importe qui dans un conteneur du VPS peut accéder à
/api/internal/*SANS auth - Une seule commande erronée
ufw allow 19210— fuite globale immédiate - 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 :
body.project_name— pas deisValidProjectName(peut passer../evilou vide)- Pas de vérification que le projet existe
- Pas d'owner_id (mais c'est voulu comme interne — dépend de S1)
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 :
- ajouter
location ~ ^/(state|scripts|config|data)/ { deny all; }pour la défense en profondeur.
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)
- C1 :
routeSseRequest— ajoutercanAccessProject - C2 :
/ws/terminal/:name— ajoutercanAccessProject+ admin uniquement pourinteractive - C3 :
/api/cli/*+/api/mcp/*— ajouter la gardecanAccessProjectau niveau du bloc
Cette semaine
- S1 :
Bun.serve({ hostname: "127.0.0.1" }) - S2 :
isValidProjectNamedans/api/internal/chat/save
Backlog
- M1 :
timingSafeEqualdansverifyToken - M2 : Nginx : ajouter
/config/,/data/à la deny list - M3 : Révision de tous les endpoints pour
safePath
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é
- 8/8 patches corrects, sans régressions
- Défense en profondeur dans M3 (regex + safePath)
- Vérification correcte de longueur AVANT
timingSafeEqualdans M1 - Interactif C2 : CEO OU rôle admin (rôle binaire) — correct
- Export des helpers (
extractChatId,canAccessProject) — sans duplication de logique - Le développeur a trouvé V0 que Sentinel avait manqué
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é.