nas-runbooks/common/mcp-nas-security-audit.md

86 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Audit sécurité mcp-nas — RCE root anonyme exposée Internet (2026-07-08)
**Tags** : infra, securite, mcp-nas, rce, docker-socket, secrets, rotation
**Périmètre** : conteneur `linux-mcp-nas` (`mcp-nas.nd.i234.me`, port hôte 3042→8000, réseau Docker `n8n`).
**Complète** : `common/audit-securite-reseau-nas-20260703.md` (couche réseau/WAN). Ici = **couche applicative**.
**Statut** (MàJ 2026-07-09) : **P0 fermés** (exposition anonyme, docker.sock, clés SSH Contabo) + **durcissement non-disruptif manche 3 fait** (rédaction audit.log, scrub historique, token Gitea scopé). **Reste** : rotation des credentials fuités (disruptif, à séquencer) + run non-root. Détail en bas.
## Verdict
**Compromission totale possible par un attaquant anonyme sur Internet.** Chaîne vérifiée depuis un poste externe, sans aucun credential :
1. **Endpoint MCP sans authentification.** `POST https://mcp-nas.nd.i234.me/mcp``initialize` répond, `tools/list` renvoie `run_command`. Le bloc nginx (`deploy/nginx-reverse-proxy.conf`) fait `proxy_pass http://localhost:3042` **sans `auth_basic`, sans allowlist IP, sans vérif de token**. Les routes OAuth du serveur (`/register` → `client_id:"public"`, `/token`, `/authorize`, `/.well-known/...`) sont des **stubs décoratifs** : elles ne valident aucun bearer. FastMCP n'a aucun auth provider. → **shell arbitraire pour tout le monde**.
2. **Évasion vers root hôte via `docker.sock`.** Le conteneur tourne en **root** (uid=0) avec `/var/run/docker.sock` monté (RW). L'API Docker est joignable (`curl --unix-socket /var/run/docker.sock http://localhost/version` OK). → créer un conteneur privilégié montant `/` = **root sur le NAS**. Le binaire `docker` n'est pas installé mais le socket suffit (curl). Le serveur MCP **n'utilise jamais** ce socket (aucune référence dans le code) → mount inutile.
3. **Pivot latéral.** Clés SSH privées `/root/.ssh/claude_oversight_contabo{,_v2,_v3}` **dans le conteneur** → accès VPS Contabo.
4. **2e chemin root hôte.** Le conteneur ssh vers `Best0f@172.17.0.1:22222` (host-gateway) avec le mot de passe sudo passé en clair → sudo hôte.
## audit.log = coffre à secrets en clair
`/mnt/docker/linux-mcp-nas/audit.log` (volume hôte RW, **lisible par la RCE**) journalise chaque commande entière. Relevé au 2026-07-08 : mot de passe sudo NAS en clair **266×**, **102** URLs `http://bolbol:MDP@.../repo.git`, mots de passe n8n / Portainer, **127** clés Bifrost (`x-bf-vk`), **19** Bearer. Le mot de passe maître est **réutilisé** (même préfixe pour sudo / n8n / Portainer). → un attaquant `cat` le log et récupère tout le trousseau.
## Autres surfaces
- **Volumes RW** : `/volume1/docker` monté RW → tous les `.env` de l'infra (43 conteneurs) lisibles ET modifiables par la RCE. Doit être `:ro`.
- **Denylist** (`DENY_ANCHORED`/`DENY_GLOBAL` dans `linux_mcp_server.py`) **contournable trivialement** : segment après `&&`/`;`, `find -delete`, `python -c`, docker.sock, ssh+sudo. C'est de la défense en profondeur, **pas une frontière de sécurité** — ne jamais s'y fier.
- **Contournement Gitea-API-HTTP** (quand git absent du NAS) : utilise le **mot de passe du compte `bolbol` complet** dans l'URL de clone (→ audit.log + `argv` visible en `ps`), au lieu d'un token scopé. Sur-privilège (accès à tous les repos) + fuite.
- **Gemini : aucune fuite.** Le serveur ne référence ni Gemini ni Bifrost (grep code = 0 résultat) ; la sortie texte ne part que vers NyoraNotes. La frontière « exclusif Claude » tient au niveau code : aucune commande mcp-nas n'alimente un prompt Gemini (Gemini n'est câblé que pour la vision dans Bifrost, et mcp-nas ne produit pas d'image).
## Correctifs priorisés
**P0 — urgence (RCE root exposée) :**
1. **Couper l'exposition publique** : retirer la règle nginx `mcp-nas.nd.i234.me` OU la restreindre au réseau WireGuard uniquement (pas de `proxy_pass` depuis le WAN). Si un accès distant reste nécessaire : `auth_request` nginx ou un secret partagé obligatoire (header) + implémenter une vraie validation bearer côté serveur.
2. **Supprimer le mount `docker.sock`** du `docker-compose.yml` (jamais utilisé par le code).
3. **Run non-root** : `user: "1026:100"` + `cap_drop: [ALL]` + `security_opt: no-new-privileges`.
4. Ne plus embarquer le mot de passe sudo dans les commandes ; **sortir les clés SSH** du conteneur (ou les monter en lecture seule depuis un secret) ; monter `/mnt/docker` en **`:ro`** (garder RW uniquement le sous-chemin strictement nécessaire).
5. **Rédiger les secrets** avant écriture dans audit.log (regex password/token/bearer/x-bf-vk → `***`) + `chmod 600` + rotation du fichier.
6. **ROTATION complète** des credentials fuités : mot de passe sudo NAS `Best0f`, n8n, Portainer `bestof`, compte Gitea `bolbol`, **toutes** les clés virtuelles Bifrost, clés SSH Contabo.
**P1 :** token Gitea repo-scopé (au lieu du mot de passe bolbol) via credential helper, jamais dans l'URL ; rate-limiting/fail2ban sur l'endpoint.
**P2 :** healthcheck ne teste que `/health` pas `/mcp` (connu) ; dérive doc `MCP_MAX_OUTPUT_CHARS` (README dit 2000, défaut code 8000).
## Correctifs APPLIQUÉS le 2026-07-08
- **P0 #1 — exposition fermée (nginx).** `/etc/nginx/conf.d/http.mcp-nas.conf` (backup `.bak-audit-20260708`).
- **Piège connecteur claude.ai : il n'accepte PAS de header `Authorization` brut, uniquement OAuth (Client ID/Secret).** Le gate Bearer ne marche donc pas avec le connecteur. → auth par **CHEMIN SECRET** : `location /<secret>/ { proxy_pass http://localhost:3042/; }` (le `/` final strippe le préfixe). Le connecteur utilise l'URL `https://mcp-nas.nd.i234.me/<secret>/mcp`, champs OAuth **vides**.
- `/mcp` direct reste gaté par `Authorization: Bearer <secret>` (pour scripts/curl). `/health` + stubs OAuth ouverts.
- Le `<secret>` (64 hex) N'EST PAS dans ce runbook — il est chez Nabil (mémoire Claude Code + fichier NAS `linux-mcp-nas/` scratch). Rotation = regénérer + MAJ nginx + MAJ URL connecteur.
- **P0 #2 — évasion hôte fermée (compose).** Mount `docker.sock` retiré + `security_opt: no-new-privileges` + `cap_drop: [ALL]` dans `docker-compose.yml` (backup `.bak-audit-20260708`). Recréé, `healthy`, `run_command` OK.
**Reste (non fait) :** P0 #3 (clés SSH Contabo hors conteneur + pwd sudo), P1 #4 (rédaction audit.log + **rotation credentials**), run non-root (`user:1026:100`), token Gitea repo-scopé.
## Manche 3 (2026-07-08) — durcissement non-disruptif FAIT et vérifié
- **Rédaction audit.log DÉPLOYÉE.** `_redact` / `_SECRET_PATTERNS` ajoutés dans `linux_mcp_server.py` : masquage **à l'écriture** de `user:pass@` (URL), `Bearer`/`token <val>`, `x-bf-vk`, `sshpass -p`, `echo '…' | sudo`, `<<< "…"` vers sudo, `curl -u user:pass`, `NOM…PASS/PWD/SECRET/TOKEN/KEY=…`, JSON `"password":…`. Testé en live (`curl -u bob:***`, `token ***` masqués). **N'affecte PAS l'exécution** (redaction sur la copie journalisée uniquement). → clôt P0 #5 / P1 #4 (volet rédaction).
- **audit.log HISTORIQUE scrubbé à 0.** Après scrub : famille `2L2u519w` = **0**, URLs `user:pass@` non masquées = 0, `x-bf-vk` / `Bearer` = 0. Méthode : one-off python (mêmes patterns + filet littéral via env `SCRUB_LITERAL_RE`), **remplacement d'inode** puis recréation du conteneur pour rouvrir le fd d'écriture. **Backup pré-scrub SUPPRIMÉ** (il contenait 278 mots de passe en clair). Temp `.claude-tmp` nettoyés. Fichier en `chmod 600 root:root`.
- **Token Gitea repo-scopé créé** (`mcp-nas-deploy`, scope `write:repository`) → `hermes-platform/.env` sous `GITEA_DEPLOY_TOKEN` (gitignoré). Vérifié : clone OK, API admin = 403 (bien limité). Remplace le **mot de passe `bolbol` complet** dans les URLs de clone/API. → clôt P1 (token scopé).
- **Versionné via API Gitea** : `linux_mcp_server.py` (commit `c4f3b2d`) + `docker-compose.yml` (`4094366`) sur `bolbol/linux-mcp-nas` main.
### 🔑 Astuce git/SMB réutilisable (découverte)
`git` sur le share SMB `/Volumes/docker` **hang** (timeouts ~2 min : le `.git` est sur SMB) **et git est absent de l'hôte NAS**. → **NE PAS** faire `git push` depuis le Mac sur ce share. À la place, pousser via l'**API HTTP Gitea `contents`** (aucun git requis, `curl` vers `gitea.bolbol.tn` répond vite) :
```sh
TOK=$GITEA_DEPLOY_TOKEN # scope write:repository suffit pour contents API
# 1) récupérer le sha courant du fichier
SHA=$(curl -s -H "Authorization: token $TOK" \
"https://gitea.bolbol.tn/api/v1/repos/OWNER/REPO/contents/PATH?ref=main" | jq -r .sha)
# 2) PUT le nouveau contenu (base64) avec le sha
curl -s -X PUT -H "Authorization: token $TOK" -H "Content-Type: application/json" \
"https://gitea.bolbol.tn/api/v1/repos/OWNER/REPO/contents/PATH" \
-d "{\"message\":\"msg\",\"content\":\"$(base64 -i FICHIER)\",\"sha\":\"$SHA\",\"branch\":\"main\"}"
```
Marche aussi depuis le conteneur mcp-nas (pas de git dedans). Pour créer un fichier neuf : même PUT **sans** `sha`.
## Vérif rapide (depuis un poste externe)
```sh
# Doit ÉCHOUER (401) — plus de RCE anonyme :
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://mcp-nas.nd.i234.me/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}'
# Doit MARCHER (200) via le chemin secret (URL du connecteur) :
curl -s -X POST https://mcp-nas.nd.i234.me/<secret>/mcp -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d '{...initialize...}'
```