La règle que vous avez écrite, et celle que Docker a écrite
Deux programmes modifient le même pare-feu avec des hypothèses différentes, et un seul des deux vous prévient. ufw écrit ses règles dans la chaîne INPUT de la table filter — le chemin que prend un paquet quand il est destiné à l'hôte lui-même. Docker écrit dans la table nat et dans FORWARD — le chemin que prend un paquet quand il est destiné ailleurs.
Suivez un seul paquet et l'écart devient évident. Quelqu'un dans un autre pays ouvre une connexion vers votre adresse sur le port 5432. Il arrive, et la première chose qu'il rencontre est nat PREROUTING, qui l'envoie vers la chaîne DOCKER de Docker. Là, une règle DNAT réécrit la destination en 172.17.0.2:5432 — le conteneur. Le paquet n'est plus adressé à votre serveur, donc le noyau le route au lieu de le délivrer localement : il traverse FORWARD, où Docker a déjà installé un ACCEPT pour le trafic à destination d'un port publié. À aucun moment de ce trajet le paquet ne passe par INPUT, qui est la seule chaîne qu'ufw filtre. Votre règle n'a jamais été fausse. Elle n'a jamais été consultée.
C'est aussi pourquoi le symptôme est si déroutant. ufw status verbose continue de rapporter Default: deny (incoming), avec rien d'autorisé à part 80, 443 et votre port SSH, pendant qu'un scanner sur un autre continent tient une session ouverte vers votre base de données. Les deux affirmations sont vraies. Le pare-feu fait exactement ce que vous avez configuré, sur le trafic qui lui est donné, et le trafic qui compte ne lui est pas donné.
Rien de tout cela n'est un bug, et Docker n'y est pour rien par négligence. Un moteur de conteneurs doit programmer des règles NAT et de forwarding, sinon les conteneurs ne peuvent pas du tout atteindre le réseau, et il ne peut pas deviner sans risque lesquelles des règles de votre pare-feu hôte étaient censées s'appliquer à eux. Alors il fait ce qui est honnête : il gère ses propres chaînes, et il vous confie une chaîne dédiée — DOCKER-USER, évaluée avant tout le reste dans FORWARD — qu'il promet de ne jamais écraser. L'écart n'est pas l'existence du mécanisme. L'écart, c'est que -p 5432:5432 se lit comme "rendre ceci disponible" et signifie "publier ceci sur chaque adresse à laquelle cette machine répond, en contournant le pare-feu que vous avez passé une heure à écrire".
À quoi ressemble un port exposé, vu de l'extérieur
Internet remarque les choses plus vite qu'on ne l'imagine. Les préfixes d'hébergement sont scannés en continu et de façon exhaustive — pas par quelqu'un qui s'est pris d'intérêt pour vous, mais par des crawlers commerciaux, des projets de recherche et des botnets opportunistes qui balaient chaque adresse routable sur chaque port intéressant et publient ou vendent les résultats. L'intervalle entre docker compose up -d et la première connexion non sollicitée vers un port de base de données fraîchement publié se mesure en heures. Personne n'a eu besoin de deviner votre hostname. Personne n'a eu besoin de connaître votre nom. L'adresse était dans la plage scannée.
Ce qu'ils trouvent dépend entièrement de ce que vous avez publié, et les cas courants sont sombres. Un conteneur PostgreSQL ou MySQL démarré depuis l'extrait de démarrage rapide d'une image, avec le mot de passe trivial de cet extrait encore en place. Une instance Elasticsearch ou MongoDB jamais configurée pour exiger une authentification, parce qu'elle ne devait être joignable que depuis le conteneur applicatif. Un memcached qui répond à n'importe qui — ce qui n'est pas seulement une fuite de données mais aussi un amplificateur qu'on peut pointer vers quelqu'un d'autre, faisant de votre serveur un participant au genre d'inondation décrit dans notre runbook de première heure contre une attaque DDoS. Un panneau d'administration ou un tableau de bord de file de messages qui présupposait un réseau privé. Un endpoint de métriques qui liste tranquillement chaque service interne, hostname et version que vous faites tourner.
Cela vaut la peine de nommer les dégâts de second ordre, parce que les dégâts de premier ordre ne sont pas toute la facture. Une base de données joignable depuis internet n'est pas seulement lisible — dans la plupart des moteurs, elle est aussi inscriptible, ce qui signifie que l'intrus n'a besoin d'aucun exploit supplémentaire pour installer une persistance, et plusieurs moteurs peuvent être amenés à écrire des fichiers ou à exécuter des commandes sur l'hôte depuis une session privilégiée. Sur une machine que vous avez choisie précisément parce qu'elle ne porte aucune identité, un point d'appui non authentifié est aussi un lien vers tout ce que cette machine touche par ailleurs : la cible de sauvegarde qu'elle peut atteindre, les clés dans ses variables d'environnement, les autres conteneurs sur son bridge.
La partie inconfortable, c'est que rien de tout cela ne s'annonce. Il n'y a pas de ligne de journal qui dit "votre pare-feu a été contourné". Le service fonctionne, l'application se connecte, le site est en ligne, et le seul signal externe est un compteur de connexions que personne ne surveille. L'exposition est découverte soit par vous, délibérément, dans les dix minutes qui suivent — soit par quelqu'un d'autre, à son propre rythme.
SP·03Lisez ce que vous publiez réellement
Commencez par ce que le moteur pense être en train de faire. docker ps affiche une colonne PORTS, et la distinction qu'elle contient est tout le sujet de ce guide : 0.0.0.0:5432->5432/tcp signifie toutes les adresses de la machine, 127.0.0.1:5432->5432/tcp signifie loopback uniquement, et un simple 5432/tcp sans flèche signifie que le port est exposé aux autres conteneurs et publié nulle part. Lisez chaque ligne de cette colonne, sur chaque conteneur, avant de changer quoi que ce soit.
Regardez ensuite les sockets avec ss -tulpen. Sur une installation par défaut, vous verrez docker-proxy détenir les ports publiés, parce que Docker démarre encore un petit processus en espace utilisateur par port publié. Voici le piège qui coûte une après-midi à pas mal de monde : si userland-proxy est désactivé sur votre démon — c'est un réglage de tuning courant, et certaines distributions le livrent ainsi — il n'y a alors aucun socket en écoute sur l'hôte. ss ne montre rien, lsof ne montre rien, et le port reste grand ouvert, parce que la règle DNAT du noyau fait le travail sans qu'aucun processus n'ait besoin de tenir l'adresse. Une sortie ss silencieuse n'est pas la preuve d'un port fermé.
Alors lisez les règles elles-mêmes. iptables -t nat -S DOCKER liste une ligne DNAT par port publié, et chaque ligne porte la réponse que vous cherchez : une règle avec -d 127.0.0.1/32 est une publication en loopback, et une règle sans contrainte de destination s'applique à chaque adresse que détient la machine. Faites de même avec ip6tables, parce que les deux familles sont configurées indépendamment, et qu'une machine peut être verrouillée sur l'une et ouverte sur l'autre.
Enfin — et c'est la seule étape qui prouve réellement quelque chose — regardez la machine depuis ailleurs. Chaque commande ci-dessus tourne sur l'hôte et hérite de la vision que l'hôte a de son propre réseau. Le trafic loopback saute les chaînes qui comptent, donc que curl 127.0.0.1:5432 réussisse ne vous dit rien sur la capacité d'un inconnu à faire de même, et son échec vous en dit encore moins. Le test qui fait autorité est un scan depuis une autre machine, sur un autre réseau, sur les deux familles d'IP. Tout ce qui précède n'est qu'une hypothèse.
La liaison en loopback, et la différence entre ports et expose
Le plus petit correctif utile tient en onze caractères. -p 127.0.0.1:5432:5432 dit à Docker d'écrire sa règle DNAT avec une contrainte de destination, si bien que la réécriture ne s'applique jamais qu'au trafic déjà local. Un paquet distant visant votre adresse publique ne correspond plus, n'est pas transmis au conteneur, et arrive enfin là où vous aviez toujours supposé qu'il arriverait : INPUT, où ufw le refuse. Dans un fichier Compose, la même chose s'écrit ports: ["127.0.0.1:5432:5432"], et les guillemets comptent — une valeur non guillemetée contenant des deux-points est un accident de parsing qui n'attend que de se produire.
Mais la meilleure question est de savoir pourquoi le port est publié, tout court. Les conteneurs attachés au même réseau défini par l'utilisateur se joignent directement, par nom de service, sur le port propre du conteneur, sans aucune forme de publication. Votre application ne se connecte pas à 127.0.0.1:5432 ; elle se connecte à postgres:5432, résolu par le DNS intégré de Docker vers une adresse sur le bridge privé. Une base de données dans cette configuration n'a besoin d'aucune ligne ports: — ni en loopback, ni d'aucune sorte. Le port publié le plus sûr est celui que vous avez supprimé. Gardez ports: pour le seul ou les deux services qui font réellement face au public, et laissez tout le reste discuter sur le réseau privé.
C'est aussi là qu'expose: est mal compris. Elle ne publie rien et n'ouvre rien ; c'est de la documentation qui note sur quel port un service écoute, et cela n'a aucun effet sur le pare-feu, dans un sens comme dans l'autre. Les gens l'ajoutent en espérant que c'est la version sûre de ports:, ce qu'elle est — au même sens qu'un commentaire est la version sûre du code. Si vous voulez un service joignable seulement par ses voisins, vous n'avez pas besoin d'expose: ; vous avez besoin de l'absence de ports:.
Deux limites honnêtes à la liaison en loopback. Premièrement, elle protège la frontière de l'hôte, pas le voisinage : les conteneurs sur le même réseau bridge peuvent toujours se joindre librement, donc un conteneur frontal compromis a un chemin direct vers une base de données qui ne publie rien. Séparez les services sur des réseaux distincts et marquez celui du back-end internal: true quand le rayon de l'explosion compte. Deuxièmement, 127.0.0.1 est une adresse IPv4 et ne contraint que l'IPv4 ; si l'hôte dispose d'un /64 routable — chaque plan ici en fournit un — raisonnez sur le v6 séparément, et testez-le séparément.
DOCKER-USER : la chaîne qui existe exactement pour ça
La liaison en loopback corrige les conteneurs dont vous vous souvenez. DOCKER-USER est ce qui vous évite d'être à un seul -p du prochain incident. Docker l'installe comme premier saut dans FORWARD, avant ses propres règles d'acceptation, et — contrairement à tout le reste dans ses chaînes — en laisse le contenu intact à travers les redémarrages, les mises à niveau et les nouveaux conteneurs. C'est l'endroit prévu pour la politique que le moteur ne peut pas déduire lui-même : quelles sources sont autorisées à atteindre les conteneurs de cet hôte, tout court.
Le schéma, ce sont trois règles sur l'interface publique, et l'ordre est tout l'enjeu. D'abord, laissez repartir le trafic established et related, pour que les réponses aux connexions ouvertes par vos conteneurs continuent de circuler. Ensuite, laissez repartir les sources que vous voulez vraiment laisser entrer — une adresse de bureau, un hôte de supervision, un serveur pair. Enfin, droppez tout le reste arrivant depuis internet. Se tromper sur la règle conntrack est l'échec classique ici : placez le DROP en premier, et chaque connexion sortante de chaque conteneur meurt sur le paquet de retour, ce qui se présente comme "Docker a cassé le DNS et les installations de paquets" et envoie les gens chercher dans une direction complètement fausse.
Deux détails opérationnels décident si cela survit au contact de la réalité. Cela doit être limité à l'interface publique par son nom — -i eth0, ou ce que rapporte ip route get 1.1.1.1 sur votre machine — sinon vous droppez aussi le trafic entre vos propres bridges. Et cela doit être réappliqué après chaque redémarrage, après que le démon Docker a créé la chaîne. Une unité systemd oneshot ordonnée avec After=docker.service est la forme fiable ; sauvegarder avec netfilter-persistent fonctionne aussi, tant que vous acceptez que restaurer tout un jeu de règles pendant que Docker le reconstruit simultanément est une course que vous devriez vérifier plutôt que supposer gagnée.
Vient ensuite la moitié IPv6. Si ip6tables -S DOCKER-USER affiche une chaîne, reproduisez-y chaque règle. Si cela renvoie une erreur, votre démon ne gère pas du tout les règles v6 — ce qui ne vous dit rien sur le fait que le trafic v6 atteigne vos conteneurs, seulement que Docker ne le filtre pas. Ne cherchez pas à raisonner jusqu'à une réponse ici ; les chemins diffèrent selon la version, les réglages du démon et la distribution, et une conclusion fausse mais confiante est pire que pas de conclusion du tout. Scannez-vous avec nmap -6 et croyez le résultat.
Une chose à ne pas faire : dégainer "iptables": false dans /etc/docker/daemon.json. Cela empêche Docker de toucher au pare-feu, et cela empêche aussi que le NAT des conteneurs, le masquerading sortant et l'isolation inter-réseaux soient configurés par quoi que ce soit. Vous n'avez pas supprimé le problème, vous en avez hérité la charge — à la main, pour chaque conteneur, pour toujours. Sur un VPS unique, le choix honnête est de laisser Docker gérer ses chaînes et de prendre en charge DOCKER-USER vous-même.
L'autre porte : le socket, et ce qui tourne en root
Tout ce qui précède concerne les paquets qui arrivent. Cette section concerne ce qu'ils trouvent, et il y a un point tellement pire que les autres qu'il mérite d'être énoncé à part : monter /var/run/docker.sock dans un conteneur équivaut à donner à ce conteneur les droits root sur l'hôte. Pas "proche de". Équivalent. Tout ce qui peut parler à ce socket peut démarrer un nouveau conteneur privilégié avec le système de fichiers de l'hôte monté à l'intérieur, et depuis là lire chaque clé, écrire chaque fichier et installer tout ce qu'il veut. De nombreuses images pratiques le demandent — tableaux de bord, outils de mise à jour automatique, reverse proxies avec service discovery. Traitez cette demande comme une décision de faire confiance à l'image autant que vous faites confiance à votre propre shell root, et prenez la même précaution avec l'API Docker sur TCP : un démon non authentifié sur le port 2375 est la même porte, ouverte à tout internet.
Au-delà de ça, le runtime de conteneurs vous offre quatre réductions peu coûteuses, et aucune n'exige de tout réarchitecturer. Tournez avec un utilisateur non-root via user: "1000:1000", parce que le défaut est root à l'intérieur du namespace, et c'est le point de départ de toutes les évasions. Réglez security_opt: ["no-new-privileges:true"], ce qui empêche un binaire setuid dans l'image de jamais obtenir plus que ce avec quoi le processus a démarré. Supprimez toutes les capabilities avec cap_drop: [ALL] et ne réajoutez que ce dont le service a un besoin démontré — la plupart des applications web n'en ont besoin d'aucune. Montez le système de fichiers racine en read_only: true et donnez-lui un petit tmpfs pour l'espace de travail, ce qui transforme "déposer un web shell dans le répertoire de l'appli" d'une simple étape en une impasse.
La même logique s'applique à ce qu'on remet au conteneur. Les secrets passés en variables d'environnement sont visibles par tout ce qui peut lire l'environnement du processus, et sont fidèlement recopiés dans la sortie de docker inspect ainsi que dans tout journal ou rapport de crash qui déverse la configuration ; un fichier monté en lecture seule à un chemin connu est moins pratique et nettement moins fuyant. Et un conteneur n'a besoin de rien de plus, côté réseau, que ce qu'exige son travail : un worker qui ne parle qu'à la base de données n'a aucune raison de pouvoir ouvrir des connexions vers internet, tout court.
Si tout cela ressemble à se battre contre le défaut, c'est une lecture juste, et c'est l'argument en faveur de Docker rootless ou de Podman — où le démon et les conteneurs tournent sous un utilisateur non privilégié, où une évasion de conteneur vous dépose en tant que cet utilisateur plutôt qu'en root, et où les ports publiés sont détenus par un processus utilisateur ordinaire sur l'hôte, ce qui signifie que vos règles ufw s'appliquent à eux de la façon habituelle. Le compromis est réel : certaines capabilities, certains drivers de stockage et certaines astuces réseau se comportent différemment, voire pas du tout. Cela vaut la peine de savoir que ce choix existe et ce qu'il apporte, plutôt que de découvrir après un incident que le défaut était lui aussi un choix.
SP·07Des images que vous n'avez pas écrites, sur une machine que vous ne pouvez pas vous permettre de perdre
Une image de conteneur est le système de fichiers de quelqu'un d'autre, qui tourne sur votre machine, assemblé à partir de couches que vous n'avez pas lues. Ce n'est pas un argument contre leur usage — c'est un argument pour savoir lesquelles vous faites tourner et depuis quand. Les deux défaillances qui arrivent réellement sur les petites infrastructures sont toutes deux banales : l'image n'a jamais été digne de confiance, ou elle l'était en mars et personne ne l'a reconstruite depuis.
La première se règle surtout par de la discipline sur la provenance des images. Préférez les dépôts officiels ou publiés par l'éditeur à un fork pratique avec trois étoiles, et méfiez-vous en particulier des images dont l'argument de vente est de regrouper six services en une ligne de YAML. Là où ça compte, épinglez par digest plutôt que par tag : postgres:17 est une cible mouvante qui peut changer sous vos pieds entre deux exécutions de docker compose pull, tandis que postgres@sha256:… est exactement le système de fichiers que vous avez testé. Épingler échange des correctifs automatiques contre de la reproductibilité, ce qui est le bon compromis quand vous avez l'habitude de reconstruire, et le mauvais quand vous ne l'avez pas.
La seconde est celle qui mord en silence. Les mises à jour automatiques ne touchent pas à vos conteneurs. Les mises à jour de sécurité automatiques que vous avez configurées lors de la première heure patchent les paquets de l'hôte et n'ont aucune visibilité sur l'espace utilisateur à l'intérieur d'une image — si bien qu'une machine qui se déclare entièrement patchée peut faire tourner un serveur web depuis une image de base avec un an de vulnérabilités non corrigées. Les conteneurs ne se mettent pas à jour, ils se remplacent : pull, recréation, et suppression de ce qui n'est plus référencé, selon un rythme que vous tenez réellement. Une fois par mois, noté quelque part, vaut mieux qu'une intention parfaite.
Ce qui fait du fichier Compose l'objet le plus précieux du serveur. C'est la seule description complète de ce qu'est cette machine, et la reconstruire à partir de lui devrait être une opération de routine plutôt qu'un projet d'archéologie — gardez-le sous contrôle de version, gardez les fichiers d'environnement à côté, et gardez les deux dans la sauvegarde chiffrée hors site qui contient vos données. Le test d'un hôte de conteneurs n'est pas de savoir s'il tourne. C'est de savoir si vous pourriez le remonter à l'identique, sur un VPS neuf, en environ 15 min.
SP·08Logs, volumes, et l'état qui survit au conteneur
Les conteneurs sont vendus comme jetables, ce qui est vrai pour le processus et faux pour tout ce qu'il laisse derrière lui. Deux types d'état s'accumulent sur un hôte Docker, et tous deux surprennent les gens au pire moment.
Le premier, ce sont les logs. Le driver json-file par défaut capture chaque ligne que vos conteneurs écrivent sur stdout et stderr, et — sauf indication contraire — il ne les fait jamais tourner. Une application bavarde peut remplir un disque de cette façon en quelques semaines, et un disque plein sur un hôte de base de données est un incident à lui seul. Fixez un plafond une bonne fois, dans /etc/docker/daemon.json, pour qu'il s'applique à tout ce que vous démarrez par la suite.
Il y a une lecture côté vie privée du même réglage, et sur un serveur choisi pour la confidentialité, c'est sans doute la plus importante des deux. Un journal d'accès qui ne tourne jamais est un enregistrement permanent et non indexé de l'adresse IP, du user agent et du chemin de requête de chaque visiteur, posé sur un disque que vous ne contrôlez pas physiquement. Vous n'avez pas décidé de garder ça. C'est le défaut qui a décidé à votre place. Décider délibérément, cela veut dire plafonner la rétention, et — mieux encore — réduire ce qui est écrit dès le départ : un format de log au niveau du proxy qui omet ou tronque les adresses des clients garde la valeur opérationnelle et abandonne le passif. Des données que vous n'avez jamais écrites ne peuvent être ni saisies, ni requises par citation, ni divulguées — le même raisonnement qui pousse les gens à garder leur nom à l'écart du serveur dès le départ.
Le second, ce sont les volumes, et l'arête vive, c'est un flag. docker compose down arrête et supprime les conteneurs et laisse les volumes nommés tranquilles ; docker compose down -v les supprime, définitivement, avec la même assurance. Les bases de données vivent dans ces volumes. Tout ce que vous regretteriez aussi. Gardez vos données dans des volumes nommés plutôt qu'anonymes, sachez quelle commande vous êtes en train de taper à deux heures du matin, et rappelez-vous que docker system prune existe pour récupérer de l'espace et se fera un plaisir de récupérer le vôtre.
Reste la sauvegarde elle-même, où l'abstraction du conteneur induit en erreur une dernière fois. Copier le répertoire de volume d'une base de données en direct n'est pas une sauvegarde ; c'est une copie de fichiers en cours d'écriture pendant que vous les lisiez, et elle se restaure en base de données corrompue exactement au moment où vous en avez le plus besoin. Sauvegardez en passant par le moteur — pg_dump, mysqldump, l'API de snapshot propre au moteur — puis chiffrez le résultat et envoyez-le quelque part que votre serveur ne peut pas atteindre. Cette dernière clause est celle qui compte quand l'intrus est déjà à l'intérieur du conteneur : une sauvegarde que votre hôte compromis peut supprimer n'est pas non plus une sauvegarde.
Étape par étape
-
01
Faites l'inventaire de ce que la machine publie en ce moment
Avant de changer quoi que ce soit, notez l'état actuel — vous voudrez le comparer plus tard. Lisez la colonne
PORTSde chaque conteneur, puis lisez les règles NAT que Docker a réellement installées, sur les deux familles d'IP. Tout ce qui s'affiche sans destination127.0.0.1est joignable depuis internet, quoi que prétendeufw status.docker ps --format 'table {{.Names}}\t{{.Ports}}' ss -tulpen | grep -E 'docker|LISTEN' sudo iptables -t nat -S DOCKER # -d 127.0.0.1/32 = loopback only sudo ip6tables -t nat -S DOCKER # may not exist; that is an answer too sudo ufw status verbose # what you believed was trueNotez le nom de l'interface publique tant que vous y êtes — les règles de pare-feu plus loin en auront besoin, et ce n'est pas
eth0sur toutes les images.ip route get 1.1.1.1 | awk '{print $5; exit}' -
02
Scannez-vous depuis ailleurs
L'hôte ne peut pas s'auditer lui-même : le trafic loopback ne traverse jamais les chaînes que vous essayez de tester. Lancez ceci depuis une autre machine, sur un autre réseau — un second VPS à partir de $8.00/mois dans une autre région est le banc de test honnête, et il reste utile par la suite. Scannez les deux familles, parce qu'elles sont configurées séparément.
# from ANOTHER machine, against your server nmap -Pn -sS -p- --min-rate 1000 203.0.113.10 nmap -Pn -6 -p 22,80,443,3306,5432,6379,9200,27017 2001:db8::1 # no nmap? one port at a time is enough to settle an argument nc -zv 203.0.113.10 5432
Chaque port ouvert qui n'est ni 80, ni 443, ni votre port SSH est un constat à noter. Gardez la sortie ; c'est la moitié "avant" de la preuve dont vous aurez besoin à la fin.
-
03
Arrêtez de publier ce qui n'a pas besoin d'être public
Réglez-le maintenant à la source, dans le fichier Compose. Les services back-end passent sur un réseau privé et perdent entièrement leur ligne
ports:— l'application les atteint par nom de service. Tout ce qui doit être joignable depuis l'hôte lui-même reçoit une liaison loopback explicite plutôt qu'un port nu. Marquez le réseau back-endinternalpour que rien dessus ne puisse parler à internet sans y être invité.services: db: image: postgres:17 # ports: ["5432:5432"] # deleted: the app reaches it as db:5432 networks: [back] volumes: [dbdata:/var/lib/postgresql/data] app: image: myapp:1.4 environment: DATABASE_URL: postgres://app@db:5432/app ports: ["127.0.0.1:8080:8080"] # loopback only; proxy sits in front networks: [back, front] networks: front: {} back: internal: true volumes: dbdata: {}Recréez la stack et confirmez que les règles NAT ont changé de forme — la ligne DNAT de l'application devrait maintenant porter une destination loopback, et la base de données ne devrait plus avoir aucune ligne du tout.
docker compose up -d docker ps --format 'table {{.Names}}\t{{.Ports}}' sudo iptables -t nat -S DOCKER -
04
Mettez exactement une seule chose face au public
Avec tout en loopback, un seul reverse proxy devient l'unique surface publique — le seul endroit où TLS est terminé, le seul endroit où une adresse client est jamais vue, et le seul endroit où un format de log est décidé. Faites-le tourner sur l'hôte ou dans un conteneur qui ne publie que 80 et 443 et rien d'autre ; s'il tourne en conteneur, il rejoint le réseau
frontet fait proxy vers des noms de service plutôt que vers le loopback.# in the http{} block of /etc/nginx/nginx.conf — no client address recorded log_format privacy '- - [$time_local] "$request" $status $body_bytes_sent'; # /etc/nginx/conf.d/app.conf (proxy on the host) server { listen 443 ssl; listen [::]:443 ssl; server_name example.com; access_log /var/log/nginx/app.log privacy; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } }Si tout l'intérêt de l'exercice est que personne n'apprenne où se trouve la machine, le proxy a sa place sur une toute autre machine — c'est le montage décrit dans cacher son IP d'origine derrière un reverse proxy, et il se combine avec tout ce qui précède.
-
05
Fermez l'écart avec DOCKER-USER, sur les deux familles
Les liaisons loopback corrigent les conteneurs d'aujourd'hui ; ceci corrige ceux que vous n'avez pas encore écrits. Trois règles sur l'interface publique, conntrack en premier pour que le trafic sortant des conteneurs continue de fonctionner, puis vos sources autorisées, puis un drop. Testez la connectivité sortante d'un conteneur immédiatement après application — c'est la règle que les gens inversent le plus souvent.
PUB=$(ip route get 1.1.1.1 | awk '{print $5; exit}') sudo iptables -I DOCKER-USER 1 -i "$PUB" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN sudo iptables -I DOCKER-USER 2 -i "$PUB" -s 203.0.113.7/32 -j RETURN # your admin address sudo iptables -I DOCKER-USER 3 -i "$PUB" -j DROP sudo iptables -S DOCKER-USER docker run --rm alpine sh -c 'apk update' >/dev/null && echo 'egress OK'Reproduisez-le pour IPv6 si le démon gère les règles v6, puis faites en sorte que cela survive à un redémarrage avec une unité qui s'exécute après que Docker a reconstruit ses chaînes.
sudo ip6tables -S DOCKER-USER || echo 'no v6 chain: verify v6 reachability by scanning' # /etc/systemd/system/docker-user-rules.service [Unit] After=docker.service Requires=docker.service [Service] Type=oneshot ExecStart=/usr/local/sbin/docker-user-rules.sh RemainAfterExit=yes [Install] WantedBy=multi-user.target
-
06
Réduisez les privilèges à l'intérieur des conteneurs, et plafonnez les logs
Réduisez ce qu'une image compromise peut faire, et empêchez l'hôte de se remplir d'un enregistrement que personne n'a choisi de garder. Les quatre réglages de conteneur ne coûtent rien sur un service web normal ; le plafond de logs s'applique à chaque conteneur démarré après le rechargement du démon.
services: app: image: myapp:1.4 user: "1000:1000" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: ["no-new-privileges:true"] # never: volumes: ["/var/run/docker.sock:/var/run/docker.sock"]# /etc/docker/daemon.json { "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }sudo systemctl reload docker docker inspect --format '{{.HostConfig.LogConfig}}' app -
07
Vérifiez depuis l'extérieur, puis rédigez le runbook
Redémarrez délibérément, pendant que rien ne dépend de la réponse, et prouvez que l'état que vous avez construit est bien l'état qui revient. Puis répétez le scan externe de l'étape deux et comparez-le à la sortie que vous avez conservée — c'est ce diff qui est le livrable, pas les commandes.
sudo reboot # after it returns: sudo iptables -S DOCKER-USER # rules reapplied? docker ps --format 'table {{.Names}}\t{{.Ports}}' # from ANOTHER machine again: nmap -Pn -p- 203.0.113.10 nmap -Pn -6 -p- 2001:db8::1Notez cinq lignes quelque part où vous les retrouverez : à quelle interface les règles sont liées, où se trouve l'unité, quels services sont délibérément publics, quand vous avez reconstruit les images pour la dernière fois, et comment restaurer les volumes. Un hôte de conteneurs ne vaut que ce que vaut la description qui vous permet de le reconstruire après le pire jour.


