Aller au contenu principal
GiwiSoft
03

Sécuriser un agent IA avec Docker : sandbox, capabilities et network isolation

Giwi 11 min de lecture DevOps, Security

Dans mon précédent article, j’expliquais pourquoi donner un shell à un agent IA est dangereux. La conclusion était claire : si vous devez le faire, mettez-le dans un sandbox. Cet article est le complément pratique : comment construire ce sandbox avec Docker, en allant bien au-delà du simple docker run.

L’idée de base est séduisante : un agent IA qui peut exécuter du code, mais dans un container éphémère, sans accès à l’hôte, avec des ressources limitées. En théorie, même si l’agent est compromis via un prompt injection, les dégâts sont contenus dans le container. En pratique, ça marche, mais seulement si on durcit correctement chaque couche. Un docker run basique ne suffit pas.

Le principe fondamental

Le container Docker n’est pas un sandbox par défaut. C’est un espace de noms (namespace) isolé, avec un cgroup pour les limites de ressources et une configuration de sécurité optionnelle. Par défaut, un container a :

  • Un accès réseau (même bridge)
  • Des capabilities Linux qui permettent des opérations sensibles
  • Un filesystem qui persiste entre les exécutions
  • Un accès potentiellement à l’hôte via les volumes montés

Tout cela doit être durci pour qu’un agent IA ne puisse pas s’échapper.

Voici le flux de sécurité que nous allons construire :

graph TD A[Agent IA - processus principal] -->|JSON-RPC| B[Serveur de dispatch] B -->|docker run --rm| C[Container sandbox] C --> D[Exécution isolée] D -->|sortie| B B -->|réponse| A C -.->|éphémère| E[Container détruit] subgraph "Hôte" A B end subgraph "Container éphémère" C D end

Le docker run complet

Voici la commande complète avec toutes les options de durcissement. On la détaillera couche par couche après.

docker run --rm \
--name sandbox-executor \
\
--cap-drop ALL \
--cap-add SETUID \
--cap-add SETGID \
--cap-add SYS_CHROOT \
\
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/tmp:rw,noexec,nosuid,size=32m \
\
--network none \
\
--memory 256m \
--cpus 1.0 \
--pids-limit 128 \
\
--security-opt no-new-privileges \
--security-opt seccomp=strict-profile.json \
\
--user 1000:1000 \
\
-v /tmp/sandbox-work:/workspace:rw \
\
sandbox-image:latest \
python /workspace/script.py

Chaque flag a un rôle précis. Regardons-les un par un.

Layer 1 : les capabilities Linux

Linux définit un ensemble de “capabilities” qui décomposent le privilege root en unités granulaires. Par défaut, Docker en conserve un sous-ensemble. On fait l’inverse : on retire tout (--cap-drop ALL) puis on n’ajoute que ce qui est strictement nécessaire.

--cap-drop ALL \
--cap-add SETUID \
--cap-add SETGID \
--cap-add SYS_CHROOT

Pourquoi ces trois-là ?

  • SETUID/SETGID : nécessaires si le container doit changer d’utilisateur (par ex. pour exécuter du code comme un utilisateur non-root mais avec besoin de changer d’identité). Beaucoup de runtimes de langages en ont besoin.
  • SYS_CHROOT : nécessaire pour certains environnements sandboxés internes (par ex. bubblewrap ou firejail à l’intérieur du container).

Toute autre capability est un risque inutile. Voici un tableau des capabilities dangereuses et pourquoi on les retire :

Capability Permet Pourquoi c’est dangereux pour un agent
SYS_ADMIN Montage de filesystems, etc. Évasion de sandbox
NET_ADMIN Configuration réseau Contourner network isolation
SYS_PTRACE Attachement à des processus Injection dans d’autres containers
SYS_MODULE Chargement de modules noyau Compromission de l’hôte
DAC_READ_SEARCH Lecture de fichiers root Accès aux secrets de l’hôte
SYS_RAWIO I/O direct hardware Compromission matérielle
SYS_TIME Modification de l’horloge Contourner des mécanismes temporels
NET_RAW Raw sockets Sniffing réseau, attaques réseau

Layer 2 : le filesystem en lecture seule

Le filesystem du container est monté en lecture seule. L’agent ne peut écrire nulle part, sauf dans les emplacements explicitement autorisés :

--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--tmpfs /var/tmp:rw,noexec,nosuid,size=32m
  • --read-only : le root filesystem du container est immuable. L’agent ne peut pas modifier les binaires, installer des paquets, ou écrire des scripts malveillants dans le filesystem.
  • --tmpfs /tmp : un filesystem temporaire en mémoire, monté avec noexec (pas d’exécution de binaires), nosuid (pas de bits setuid), et une taille limite de 64 Mo. C’est l’espace d’écriture de l’agent, mais il ne persiste pas et il est limité.
  • --tmpfs /var/tmp : idem pour /var/tmp, avec 32 Mo.

Pourquoi noexec sur tmpfs ? Parce qu’un agent compromis pourrait télécharger un binaire via curl (si le réseau était autorisé) et l’exécuter depuis /tmp. Avec noexec, cette tentative échoue. L’agent peut écrire des fichiers dans /tmp mais pas les exécuter.

Layer 3 : l’isolation réseau

C’est la couche la plus critique. Un agent avec accès réseau peut exfiltrer des données, contacter des serveurs Command & Control, ou attaquer d’autres services.

--network none

--network none supprime totalement l’accès réseau du container. Pas d’interface réseau, pas de résolution DNS, pas de communication possible avec l’extérieur. L’agent ne peut lire que les fichiers qui sont montés dans le container.

L’alternative : réseau restreint avec proxy

Parfois, l’agent a besoin d’un accès réseau limité (par ex. pour appeler une API). Dans ce cas, on utilise un réseau restreint avec un proxy d’autorisation :

# docker-compose.yml
services:
sandbox:
image: sandbox-image:latest
network_mode: "service:proxy"

proxy:
image: tinyproxy
networks:
- sandbox-net
# tinyproxy whitelist : seules les API autorisées sont accessibles
volumes:
- ./tinyproxy.conf:/etc/tinyproxy/tinyproxy.conf:ro

Le proxy filtre les requêtes sortantes. Seules les URLs explicitement autorisées sont accessibles. Tout le reste est bloqué. C’est plus complexe que --network none mais c’est parfois nécessaire.

# tinyproxy.conf
Allow 127.0.0.1
ConnectPort 443
Filter "/etc/tinyproxy/filter.txt"
# filter.txt contient les seules autorisations :
# ^api\.openai\.com$
# ^api\.anthropic\.com$

Layer 4 : les limites de ressources

Un agent compromis peut consommer toutes les ressources de l’hôte en lançant des boucles infinies ou des calculs intensifs. Les cgroups empêchent cela :

--memory 256m \
--cpus 1.0 \
--pids-limit 128
  • --memory 256m : le container ne peut pas utiliser plus de 256 Mo de RAM. Au-delà, le kernel tue le processus (OOM kill).
  • --cpus 1.0 : le container est limité à un coeur CPU. Il ne peut pas saturer l’hôte.
  • --pids-limit 128 : le container ne peut pas créer plus de 128 processus. Cela empêche les fork bombs et les attaques par déni de ressources.

Pourquoi 256 Mo ? C’est un compromis. Suffisamment pour exécuter la plupart des scripts Python/Node.js simples, mais pas assez pour entraîner un modèle ou faire du minage de crypto. Ajustez selon vos besoins, mais ne jamais laisser de limite par défaut (qui est la mémoire illimitée).

Layer 5 : no-new-privileges et seccomp

--security-opt no-new-privileges \
--security-opt seccomp=strict-profile.json

no-new-privileges empêche un processus dans le container d’acquérir de nouveaux privilèges via setuid/setgid. Même si le binaire exécuté a le bit setuid, le processus ne bénéficiera pas des privilèges élevés.

seccomp (Secure Computing) restreint les appels système autorisés. Le kernel de Linux définit des centaines d’appels système. Un agent n’en a besoin que d’une poignée. Le profile seccomp bloque tout le reste.

Voici un exemple de profile seccomp pour un agent d’exécution de code :

{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": [
"read", "write", "open", "close", "stat", "fstat",
"mmap", "mprotect", "munmap", "brk", "ioctl",
"access", "pipe", "select", "sched_yield",
"clone", "execve", "exit", "wait4", "kill",
"uname", "fcntl", "flock", "fsync", "fdatasync",
"getcwd", "chdir", "mkdir", "rmdir", "unlink",
"getpid", "getuid", "getgid", "geteuid", "getegid",
"socket", "connect", "sendto", "recvfrom",
"brk", "rt_sigaction", "rt_sigprocmask",
"nanosleep", "gettimeofday", "clock_gettime"
],
"action": "SCMP_ACT_ALLOW"
}
]
}

Le defaultAction est ERRNO (erreur silencieuse) pour tous les appels système non listés. Seuls les appels explicitement autorisés passent. C’est la couche la plus profonde de la défense.

Le Dockerfile du sandbox

Le container doit être minimaliste. Plus il y a de binaires, plus la surface d’attaque est grande.

FROM python:3.12-slim AS runtime

RUN groupadd -r sandbox && useradd -r -g sandbox -d /workspace -s /bin/bash sandbox

WORKDIR /workspace

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

RUN chown -R sandbox:sandbox /workspace

USER sandbox

ENTRYPOINT ["python"]

Points importants :

  • python:3.12-slim : image minimale, pas de compilation outils, pas de paquets inutiles.
  • Utilisateur non-root : le processus tourne en tant que sandbox:1000, pas root. Même si l’agent trouve une faille d’évasion de namespace, il est non-root dans le container.
  • Pas de curl, wget, nc : ces outils sont absents de l’image de base. Un agent compromis ne peut pas facilement télécharger du code malveillant ou ouvrir des connexions sortantes.
  • ENTRYPOINT minimal : le container exécute uniquement le script demandé.

Le docker-compose complet

Pour un agent IA avec un sandbox dédié, voici l’architecture docker-compose :

services:
agent:
image: agent-orchestrator:latest
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- SANDBOX_MODE=docker
volumes:
- agent-data:/data
networks:
- agent-net

sandbox-worker:
image: sandbox-executor:latest
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
networks:
- sandbox-net
deploy:
resources:
limits:
memory: 256M
cpus: "1.0"
pids: "128"
security_opt:
- no-new-privileges:true
user: "1000:1000"

redis:
image: redis:7-alpine
networks:
- agent-net
command: redis-server --maxmemory 50mb --maxmemory-policy allkeys-lru

networks:
agent-net:
driver: bridge
sandbox-net:
driver: bridge
internal: true

volumes:
agent-data:

Deux réseaux distincts :

  • agent-net : l’agent communique avec Redis et les APIs externes.
  • sandbox-net : le sandbox communique avec l’agent mais n’a pas accès à l’extérieur. Le flag internal: true empêche toute connexion sortante.

L’agent orchestre, le sandbox exécute. Séparation nette des responsabilités.

Le tableau de durcissement

Récapitulons chaque couche de sécurité et ce qu’elle empêche :

Couche Flag Docker Attaque empêchée
Capabilities --cap-drop ALL Évasion de namespace, accès kernel
Filesystem --read-only Modification de binaires, installation de malware
Exécution --tmpfs noexec Exécution de binaires depuis /tmp
Réseau --network none Exfiltration de données, C&C
Mémoire --memory 256m OOM, déni de service mémoire
CPU --cpus 1.0 Saturation CPU, fork bombs
Processus --pids-limit 128 Fork bombs, déni de service
Privileges no-new-privileges Escalade de privilèges via setuid
Appels système seccomp=strict Accès aux fonctions kernel dangereuses
Utilisateur --user 1000:1000 Action en tant que root dans le container
Éphémérité --rm Persistance de données entre les exécutions

Le cycle de vie d’un code sandboxé

Voici comment un agent IA exécute du code de manière sécurisée :

  1. L’utilisateur demande “écris un script Python pour analyser ce CSV”.
  2. L’agent génère le code.
  3. L’orchestrateur crée le container éphémère avec toutes les options de sécurité.
  4. Le code est monté en lecture seule dans le container (ou copié dans /tmp).
  5. Le container exécute le code avec l’utilisateur non-root.
  6. La sortie (stdout/stderr) est capturée.
  7. Le container est détruit (--rm).
  8. L’orchestrateur retourne la sortie à l’agent, qui la présente à l’utilisateur.
# Étape 3-7 : exécution sandboxée
docker run --rm \
--cap-drop ALL \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--network none \
--memory 256m \
--cpus 1.0 \
--pids-limit 128 \
--security-opt no-new-privileges \
--user 1000:1000 \
-v /tmp/agent-code/script.py:/workspace/script.py:ro \
sandbox-executor:latest \
/workspace/script.py

Le script est monté en lecture seule (:ro). Le container ne peut pas le modifier. La sortie est capturée par l’orchestrateur via docker logs ou redirection de stdout.

Les pièges courants

Le piège du docker-in-docker

Si votre agent tourne déjà dans un container (par ex. dans Kubernetes), lancer des containers sandbox depuis ce container nécessite docker-in-docker ou un accès au socket Docker de l’hôte. Monter le socket Docker (-v /var/run/docker.sock:/var/run/docker.sock) est une faille de sécurité critique : le container peut devenir root sur l’hôte. Ne jamais faire ça dans un contexte de sandbox.

La solution : un serveur dédié qui reçoit les requêtes d’exécution et les lance dans des containers. L’agent communique avec ce serveur via une API, pas directement avec Docker.

Le piège du persistent volume

Un volume persistant entre les exécutions permet à un agent compromis de stocker du code malveillant pour une exécution future. Utilisez toujours des volumes éphémères ou des tmpfs. Si vous devez conserver des données entre les exécutions, stockez-les dans un service externe (base de données, objet storage) avec des politiques d’accès strictes.

Le piège du filesystem monté en écriture

Monter un répertoire de l’hôte en écriture dans le container, même avec un chemin spécifique, donne à l’agent la capacité de modifier des fichiers de l’hôte. Si vous devez monter des fichiers, faites-le en lecture seule (:ro). Si vous devez permettre l’écriture, utilisez un tmpfs ou un volume dédié non monté sur l’hôte.

Monitoring et audit

Un sandbox sans monitoring est un sandbox aveugle. Quand un agent est compromis, vous devez pouvoir le détecter et l’analyser.

# Logs de tous les containers sandbox
docker logs --since 1h sandbox-executor 2>&1 | \
grep -iE "error|warning|permission|denied" | \
tee -a /var/log/sandbox-audit.log

# Surveillance des ressources
docker stats --no-stream --format \
"table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}" | \
grep sandbox

En production, connectez ces logs à un système d’alerte. Un container qui consomme 100% de sa mémoire, qui tente des appels système refusés par seccomp, ou qui génère beaucoup de processus est un signal d’alerte.

Pour aller plus loin

Ce durcissement Docker est un excellent point de départ, mais pour des cas d’usage plus exigeants, voici les pistes :

  • gVisor : un noyau utilisateur qui intercepte les appels système. Couche supplémentaire au-dessus de Docker, avec un model sandbox encore plus strict.
  • Firecracker : des microVMs pour un isolation encore plus forte. Utilisé par AWS Lambda.
  • eBPF : des policies de sécurité dynamiques au niveau du noyau, pour surveiller et contrôler le comportement des containers en temps réel.
  • Sysbox : un runtime OCI qui renforce l’isolation des containers sans les limitations de performance des VMs.

Le Docker durci n’est pas la solution ultime, mais c’est le meilleur rapport effort/sécurité pour la majorité des cas d’usage d’agents IA. L’important est de comprendre chaque couche, de savoir ce qu’elle protège, et de ne jamais considérer le sandbox comme magique. La sécurité est un processus, pas un état.