Skip to main content

Bonnes pratiques pour conteneuriser des applications Python avec Docker

Écrit par

Daniel Campos Olivares

blog feature snyk python security

11 novembre 2021

0 minutes de lecture

Après avoir lu de nombreux articles de blog sur les conteneurs Docker pour Python, nous avons constaté que la plupart proposent des exemples de conteneurisation d’une application Python indépendamment de son framework (Django, Flask, Falcon, etc.). Par exemple, on peut trouver quelque chose comme ceci :

FROM python
WORKDIR /usr/app
COPY . .
RUN pip install -r requirements.txt
CMD [ "python", "app.py" ]

Avec ce Dockerfile, nous pouvons créer et exécuter une application Python Flask :

docker build -t flask-application .
docker run -p 8080:5000 flask-application

Deux étapes simples, et tout fonctionne parfaitement, n’est-ce pas ?

Cet exemple est simple et utile pour les démonstrations et les tutoriels de prise en main, mais il laisse de nombreuses préoccupations importantes sans réponse. Dans cet article, nous allons donc nous pencher sur ces préoccupations et présenter six bonnes pratiques pour conteneuriser des applications Python avec Docker. Nous verrons pourquoi vous devriez :

  1. Utiliser des balises d’image de base Docker explicites et déterministes pour les applications Python conteneurisées.

  2. Séparer les dépendances du code source.

  3. Utiliser Python WSGI en production.

  4. Exécuter les conteneurs avec le moins de privilèges possible (et jamais en tant que root).

  5. Gérer les états dégradés de votre application.

  6. Détecter et corriger les vulnérabilités de sécurité dans l’image Docker de votre application Python.

1. Utiliser des balises d’image de base Docker explicites et déterministes pour les applications Python conteneurisées

Il peut sembler logique d’utiliser python comme image de base pour notre application Python conteneurisée avec Docker, mais cela ne précise pas quelle version de Python est utilisée.

Au moment de la rédaction de cet article, l’image de base mentionnée plus haut dans le Dockerfile correspond à une image avec Python 3.10. Pourquoi ? Comme nous n’avons pas ajouté de balise spécifique, la version :latest de cette image de base a été utilisée par défaut. Or, comme on peut le voir sur la page officielle de l’image sur Docker Hub, il s’agit de la version 3.10.

Comme nous voulons contrôler les versions de Python que nous conteneurisons, nous devons toujours indiquer la version dans le Dockerfile.

Puisque je veux utiliser Python 3.10, il me suffit d’ajouter la balise :3.10 à mon Dockerfile, n’est-ce pas ?

Eh bien… pas tout à fait.

L’image de base Docker avec la balise :3.10 est un système d’exploitation complet sur lequel Python 3.10 est installé. Elle contient un grand nombre de bibliothèques dont vous n’aurez probablement jamais besoin. De plus, cette quantité importante de logiciels a un effet secondaire : elle augmente votre surface d’attaque en raison des vulnérabilités de sécurité présentes dans ces bibliothèques.

Si vous introduisez une image Docker Python volumineuse, il sera plus difficile d’en assurer la maintenance et de maintenir à jour toutes les versions de ces bibliothèques.

Si nous utilisons l’outil Snyk Advisor pour examiner une image de base Python, nous constatons que l’image Docker de base python:3.10 présente 12 problèmes de gravité élevée, 27 de gravité moyenne et 132 de gravité faible. Par défaut, l’image Docker Python démarre donc avec au moins 171 vulnérabilités de sécurité — et nous n’avons encore rien ajouté !

De plus, comme nous introduisons en réalité un système d’exploitation complet, la taille de l’image de base sera considérable pour notre serveur d’application Python. Cela ralentira les builds et nécessitera davantage d’espace de stockage. En général, il existe quelques règles à suivre lors du choix d’une image de base, mais deux d’entre elles sont essentielles.

Bonnes pratiques pour choisir une image Docker Python

  1. Choisissez l’image de base la plus légère qui répond à toutes vos exigences, puis construisez par-dessus. Les images plus légères contiennent moins de vulnérabilités, consomment moins de ressources et incluent moins de paquets superflus..

  2. L’utilisation d’une balise nommée ne suffit pas à garantir que vous utiliserez toujours la même image de base. La seule façon de le garantir est d’utiliser le digest de l’image.

Maintenant que nous savons cela, revenons sur Snyk Advisor pour vérifier les autres balises recommandées. Cet outil offre un excellent aperçu des vulnérabilités et de la taille des images de base, ce qui nous aidera beaucoup à faire notre choix.

Comme nous souhaitons utiliser Python 3.10 dans notre application, nous allons rechercher cette balise précise.

Page Snyk Advisor pour l’image Docker officielle de Python, affichant la commande docker pull et des recommandations de tags alternatifs avec des données sur la gravité, les mises à jour et la taille

En plus des vulnérabilités mentionnées plus haut, nous constatons que cette image de base pèse environ 350 Mo, repose sur Debian 11 et contient 427 paquets installés. Pour notre petite application Python, cette image nous semble un peu trop lourde.

D’un autre côté, il existe également l’image de base Docker Python :3.10-slim, qui présente un problème de gravité élevée, un de gravité moyenne et 35 de gravité faible. L’image de base Docker pèse 46,2 Mo, repose elle aussi sur Debian 11 et contient 106 paquets installés. En choisissant cette image de base Docker plutôt que celle par défaut pour notre serveur d’application Python, nous réduirons le nombre de vulnérabilités de sécurité, l’espace disque utilisé et le nombre de bibliothèques installées, tout en répondant à nos besoins en matière de version Python de l’application : :3.10.

Et voilà ! Il me suffit d’ajouter la balise :3.10-slim et le tour est joué !

Presque ! Nous avons respecté la première règle — choisir une petite image de base Docker adaptée à nos besoins —, mais nous devons encore régler le deuxième problème. Nous devons nous assurer d’utiliser exactement la même image de base Docker à chaque création de notre serveur d’application Python.

Pour ce faire, plusieurs possibilités s’offrent à nous :

  1. Récupérer le digest de l’image de base Docker sur Docker Hub.

  2. Télécharger l’image Docker sur notre ordinateur avec docker pull python:3.10-slim, ce qui affiche le digest de l’image Docker :

3.10-slim: Pulling from library/python
7d63c13d9b9b: Pull complete
6ad2a11ca37b: Pull complete
1d79bc863ed3: Pull complete
c72b5f03bec8: Pull complete
0c3b0c5ce69b: Pull complete
Digest: sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d
Status: Downloaded newer image for python:3.10-slim
docker.io/library/python:3.10-slim

Si l’image Docker Python est déjà présente sur notre ordinateur, nous pouvons récupérer son digest à partir de l’image existante sur le disque à l’aide de la commande docker images --digests | grep python :

python    3.10-slim    sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d

Une fois le digest de l’image de base en notre possession, il nous suffit de l’ajouter au Dockerfile mentionné plus haut :

Dockerfile

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d
WORKDIR /usr/app
COPY . .
RUN pip install -r requirements.txt
CMD [ "python", "app.py" ]

Cette pratique garantit qu’à chaque recréation de l’image Docker pour cette application Python, les mêmes versions du système d’exploitation sous-jacent et des bibliothèques seront utilisées. Le build est ainsi déterministe.

2. Séparer les dépendances du code source

Cette deuxième bonne pratique permet d’éviter l’une des erreurs les plus fréquentes dans les images Docker de projets qui utilisent des dépendances. Voici d’abord la mauvaise pratique :

  1. Copier l’intégralité du dossier du projet dans le contexte de l’image.

  2. Installer les dépendances.

  3. Exécuter l’application.

Ça fonctionne, mais il y a beaucoup à améliorer. Pour commencer, lorsque vous développez un projet en local, vous n’installez les dépendances que lorsqu’elles changent, n’est-ce pas ? Alors, ne forcez pas votre image Docker à les télécharger et à les installer chaque fois que vous modifiez la moindre ligne de code.

Cette bonne pratique consiste à optimiser les couches d’une image Docker. Pour tirer parti du système de mise en cache pendant un build Docker, nous devons toujours garder une chose à l’esprit lors de la rédaction d’un Dockerfile : les couches doivent être ordonnées selon leur probabilité de changement.

Reprenons le Dockerfile que nous avons utilisé jusqu’ici. À chaque création de cette image d’application Python, le logiciel Docker examine les différentes couches et se pose la question suivante : Quelque chose a-t-il changé, ou puis-je simplement réutiliser ce que j’ai déjà fait ?

Avec notre Dockerfile actuel, toute modification apportée au dossier du projet déclenchera à nouveau l’instruction COPY, puis les couches de build suivantes. Ce n’est pas très logique et cela laisse une grande marge d’optimisation et d’accélération.

Améliorons cela avec le Dockerfile suivant :

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d
WORKDIR /usr/app

COPY requirements.txt .
RUN pip install -r requirements.txt

COPY . .
CMD [ "python", "app.py" ]

Avec ce nouveau Dockerfile, la prochaine fois que Docker vérifiera si des couches peuvent être réutilisées, s’il constate que le fichier requirements.txt n’a pas changé, il passera directement à l’instruction COPY, qui s’exécutera en quelques secondes. Cette petite modification accélère considérablement le processus de build — plus besoin d’attendre plusieurs minutes entre les builds chaque fois que nous modifions notre code.

Il est important de noter qu’il est possible que certaines de vos dépendances ne soient pas fournies sous forme de wheels. Dans ce cas, vous devrez installer un compilateur dans votre image.

Mais vous nous avez dit d’utiliser l’image la plus légère possible pour exécuter l’application !

Vous avez raison, et c’est pourquoi nous allons vous présenter une autre fonctionnalité formidable de Docker : les builds multiétapes.

Builds multiétapes

Un build multiétape consiste à utiliser une image Docker contenant davantage d’outils pour compiler les dépendances nécessaires, puis à copier uniquement les artefacts requis dans l’image Docker Python réellement utilisée.

Voici un exemple avec des applications basées sur Node.js :

FROM node:latest AS build
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN npm install

FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY . .
CMD ["node", "server.js"]

Remarque : il s’agit d’un bref exemple visant simplement à montrer les possibilités des builds multiétapes. Pour découvrir les bonnes pratiques de conteneurisation des applications Node.js, consultez cet article de Liran Tal (ce nom vous dit quelque chose…) et Yoni Goldberg.

Les builds multiétapes pour Node.js sont assez faciles à gérer, car le dossier node_modules se trouve dans le même dossier que le projet. Ce n’est toutefois pas le cas avec les applications Python.

Si nous exécutons simplement pip install, de nombreux éléments seront installés à différents endroits, ce qui rendra impossible la réalisation d’un build multiétape. Pour résoudre ce problème, deux solutions s’offrent à nous :

  1. Utiliser pip install --user

  2. Utiliser un virtualenv

Utiliser pip install --user peut sembler une bonne option, puisque tous les paquets seront installés dans le répertoire ~/.local et qu’il est donc facile de les copier d’une étape à l’autre. Mais cela crée un autre problème : vous ajouteriez à l’image de base Docker finale toutes les dépendances système de l’image utilisée pour compiler les dépendances — ce que nous voulons éviter (rappelez-vous notre bonne pratique : utiliser l’image de base Docker la plus légère possible).

Écartons donc cette première option et examinons la deuxième : utiliser un virtualenv. Nous obtiendrions alors le Dockerfile suivant.

FROM python:3.10-slim as build
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
	      build-essential gcc 

WORKDIR /usr/app
RUN python -m venv /usr/app/venv
ENV PATH="/usr/app/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d
WORKDIR /usr/app/venv
COPY --from=build /usr/app/venv ./venv
COPY . .

ENV PATH="/usr/app/venv/bin:$PATH"
CMD [ "python", "app.py" ]

Nous disposons maintenant de toutes les dépendances nécessaires, sans les paquets supplémentaires requis pour les compiler.

Problèmes connus des builds multiétapes pour les applications Python conteneurisées

Il existe actuellement un problème connu : les étapes préliminaires ne sont pas mises en cache. Le moyen le plus simple de le résoudre consiste à utiliser BuildKit et à ajouter l’argument BUILDKIT_INLINE_CACHE=1 au processus de build.

La première fois, nous effectuerons le build normalement. Pour les builds suivants, nous utiliserons la commande suivante :

export DOCKER_BUILDKIT=1
docker build -t flask-application --cache-from flask-application --build-arg BUILDKIT_INLINE_CACHE=1 .

Vous aurez besoin de la variable d’environnement DOCKER_BUILDKIT=1 pour que Docker sache qu’il doit utiliser BuildKit pour le processus de build. Vous pouvez également l’activer en ajoutant les paramètres suivants au fichier de configuration du logiciel Docker, à l’emplacement /etc/docker/daemon.json (la variable d’environnement ne sera alors pas nécessaire) :

{ "features": { "buildkit": true } }

3. Utiliser Python WSGI en production

Laisser le mode débogage activé dans les applications Python destinées à la production est une très mauvaise idée — et un incident de sécurité en puissance. Malheureusement, c’est une erreur fréquente que nous avons constatée dans de nombreux articles de blog consacrés aux applications Python Flask conteneurisées et à d’autres frameworks d’application Python WSGI.

Le débogueur permet d’exécuter du code Python arbitraire depuis le navigateur, ce qui représente un risque de sécurité énorme. On peut limiter ce risque dans une certaine mesure, mais une vulnérabilité subsistera toujours. Rappelons-le encore une fois pour la sécurité : n’exécutez pas de serveurs de développement ni de débogueur dans un environnement de production ! Cela vaut aussi pour vos applications Python conteneurisées.

Nous comprenons qu’il soit plus facile de déployer vos applications Python Flask en mode débogage que de configurer un serveur WSGI et un serveur web/proxy. Mais ce n’est plus aussi facile lorsque vous devez expliquer pourquoi votre application a été piratée.

Alors, comment résoudre ce problème ? Tout d’abord, vous devez choisir l’implémentation du serveur WSGI que vous souhaitez utiliser. Les quatre suivantes sont couramment utilisées pour les applications Python :

  • Green Unicorn (Gunicorn) — Un modèle de workers pré-fork adapté du projet Unicorn de Ruby.

  • uWSGI — Une implémentation de serveur WSGI polyvalente et performante, qui consomme peu de ressources.

  • mod_wsgi — Un module Apache capable d’héberger toute application web Python compatible avec la spécification WSGI de Python.

  • CherryPy — Un framework HTTP orienté objet, typiquement Python, qui fait également office de serveur WSGI.

Dans cet article, nous prendrons Gunicorn comme exemple, mais n’hésitez pas à consulter la documentation et les informations relatives à chacun pour choisir celui qui répond le mieux à vos besoins. Nous n’aborderons pas la configuration dans cet article, car elle dépend entièrement du cas d’usage.

Pour la conteneurisation, nous devons simplement :

  1. Inclure la dépendance gunicorn dans le fichier requirements.txt

  2. Modifier le point d’entrée du conteneur de l’application Python (reportez-vous à l’instruction CMD pour effectuer cette modification) :

FROM python:3.10-slim as build
RUN apt-get update
RUN apt-get install -y --no-install-recommends \
	build-essential gcc 

WORKDIR /usr/app
RUN python -m venv /usr/app/venv
ENV PATH="/usr/app/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d
WORKDIR /usr/app
COPY --from=build /usr/app/venv ./venv
COPY . .

ENV PATH="/usr/app/venv/bin:$PATH"
CMD [ "gunicorn", "--bind", "0.0.0.0:5000", "manage:app" ]

Après avoir reconstruit l’application Python à partir de ce nouveau Dockerfile, nous pouvons l’exécuter et vérifier que l’application Flask est prête à traiter les requêtes :

docker run -p 8080:5000 flask-application

Remarque : pour les déploiements en production, nous vous recommandons de ne pas associer directement au serveur hôte le port exposé par Gunicorn. Déployez plutôt un serveur proxy inverse sur le même réseau, chargé de traiter toutes les requêtes HTTP et de servir les fichiers statiques.

4. Exécutez vos applications Python conteneurisées avec le moins de privilèges possible (et jamais en tant que root)

Le principe du moindre privilège est une mesure de sécurité appliquée depuis les débuts d’Unix — et nous devrions toujours le respecter lorsque nous exécutons nos applications Python conteneurisées.

L’image Docker officielle python ne contient pas d’utilisateur privilégié par défaut. Nous devons donc en créer un pour pouvoir exécuter le processus avec un utilisateur disposant du minimum de privilèges.

Pour ce faire, nous allons ajouter les commandes groupadd suivantes au Dockerfile de l’image finale (la deuxième étape de la compilation en plusieurs étapes), qui exécute le processus gunicorn :

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d

RUN groupadd -g 999 python && \
    useradd -r -u 999 -g python python
USER 999
WORKDIR /usr/app

COPY --from=build /usr/app/venv ./venv
COPY . .

ENV PATH="/usr/app/venv/bin:$PATH"
CMD [ "gunicorn", "--bind", "0.0.0.0:5000", "manage:app" ]

Le problème, toutefois, est qu’avec les modifications précédentes, l’utilisateur python est propriétaire du processus système exécuté par Docker... Qu’en est-il de la propriété des fichiers copiés ou du répertoire WORKDIR ? Par défaut, le compilateur Docker crée le répertoire WORKDIR s’il n’existe pas, mais en attribue la propriété à l’utilisateur système root. Toute opération nécessitant l’écriture dans ce répertoire risque alors de provoquer une erreur fatale dans notre application. De plus, les fichiers copiés appartiendront par défaut à root si nous ne modifions pas ce comportement, même si nous avons déjà changé d’utilisateur.

Corrigeons cela :

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d

RUN groupadd -g 999 python && \
    useradd -r -u 999 -g python python

RUN mkdir /usr/app && chown python:python /usr/app
WORKDIR /usr/app

COPY --chown=python:python --from=build /usr/app/venv ./venv
COPY --chown=python:python . .

USER 999

ENV PATH="/usr/app/venv/bin:$PATH"
CMD [ "gunicorn", "--bind", "0.0.0.0:5000", "manage:app" ]

Grâce aux instructions COPY mises à jour ci-dessus, nous évitons les problèmes imprévus liés à la propriété des fichiers dans le répertoire WORKDIR et veillons à ce que tous les fichiers appartiennent au même utilisateur que celui qui exécutera le processus.

5. Gérez les états de santé dégradés de votre application Python conteneurisée

Lorsque vous déployez une application, vous devez tenir compte des événements ou problèmes susceptibles de ne pas être gérés et de placer votre application dans un état dégradé : 1) elle ne fonctionne plus, mais 2) le processus ne s’arrête pas. Dans ce cas, votre conteneur ne sera pas averti et votre serveur d’applications Python continuera de tourner sans répondre aux requêtes HTTP.

Pour éviter cela, nous vous recommandons de mettre en place un point de terminaison de vérification de l’état de santé. Pour vérifier l’état de santé de votre application Python conteneurisée, nous recommandons toujours d’inclure un point de terminaison HTTP de monitoring ou de health, afin de vérifier que l’application est toujours en mesure de traiter correctement les requêtes des utilisateurs. Avec certains frameworks d’applications web Python, comme Flask, cette tâche est simple (voir l’exemple Flask ci-dessous). Associé à l’instruction Docker HEALTHCHECK, ce point de terminaison permet de surveiller l’état de santé de votre application.

Voici un extrait d’application Python Flask qui ajoute un point de terminaison HTTP /health :

@app.route('/health', methods=['GET'])
def health():
	# Handle here any business logic for ensuring you're application is healthy (DB connections, etc...)
    return "Healthy: OK"

Une fois ce point de terminaison intégré à votre application, il vous suffit d’ajouter l’instruction HEALTHCHECK à votre Dockerfile :

FROM python:3.10-slim@sha256:2bac43769ace90ebd3ad83e5392295e25dfc58e58543d3ab326c3330b505283d

RUN groupadd -g 999 python && \
    useradd -r -u 999 -g python python

RUN mkdir /usr/app && chown python:python /usr/app
WORKDIR /usr/app

COPY --chown=python:python --from=build /usr/app/venv ./venv
COPY --chown=python:python . .

USER 999

ENV PATH="/usr/app/venv/bin:$PATH"
CMD [ "gunicorn", "--bind", "0.0.0.0:5000", "manage:app" ]
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD curl -f http://localhost:5000/health

Si vous déployez sur Kubernetes, sachez que les directives Dockerfile HEALTHCHECK sont ignorées. Vous devez ajouter dans votre fichier YAML une sonde de disponibilité, de démarrage et de vivacité Kubernetes équivalente.

Voici donc comment transposer l’instruction HEALTHCHECK ci-dessus pour les déploiements Kubernetes :

...
   livenessProbe:
     httpGet:
       path: /health
       port: 5000
     initialDelaySeconds: 5
     periodSeconds: 30
     timeoutSeconds: 30
     failureThreshold: 3
...

Notez que cette configuration doit figurer dans la partie container spec du fichier YAML du pod ou du déploiement. Avec une stratégie de redémarrage définie sur always ou unless_stopped, le conteneur de notre application Python sera toujours redémarré s’il passe dans un état dégradé.

6. Détectez et corrigez les vulnérabilités de sécurité dans l’image Docker de votre application Python

Nous avons déjà établi que les images Docker de base volumineuses posent de nombreux problèmes, notamment parce qu’elles contiennent une pile logicielle importante qu’il faut maintenir et tenir à jour avec les correctifs de sécurité, entre autres.

Nous avons également vu comment utiliser Snyk Advisor pour comparer la taille et les indicateurs de vulnérabilité de différentes images de base. Mais Advisor n’est que la partie visible de l’iceberg. Snyk est une plateforme gratuite de sécurité pour les développeurs qui vous permet de tester aussi bien votre propre code Python que ses dépendances (comme celles du fichier requirements.txt), l’image de conteneur Python qui exécute votre application, et même les configurations Terraform ou Kubernetes qui orchestrent le tout.

L’un des grands avantages de Snyk, c’est qu’il propose des correctifs recommandés pour les vulnérabilités détectées. Au lieu de simplement vous indiquer les vulnérabilités présentes, il crée automatiquement une pull request de correction ou vous propose des conseils de remédiation. Et tout cela depuis vos outils (IDE, CLI, Docker, etc.) et workflows (Git, CI/CD, etc.) habituels.

Exemple : analyser notre application Python conteneurisée avec Snyk

Voyons comment procéder avec la CLI Snyk en utilisant l’image Docker Python python:3.8 pour créer cet exemple d’application Python Flask.

Une fois la CLI Snyk installée, vous pouvez l’utiliser pour analyser les dépendances de votre projet Python, votre code Python et bien plus encore. Commençons donc par installer la CLI Snyk. Si vous disposez d’un environnement Node.js, vous pouvez utiliser le gestionnaire de paquets npm comme suit :

npm install -g snyk

Sur macOS ou Linux, si vous utilisez Homebrew, vous pouvez également l’installer ainsi :

brew tap snyk/tap
brew install snyk

Pour découvrir d’autres méthodes d’installation, consultez notre guide sur l’installation de la CLI Snyk.

Ensuite, nous devons nous authentifier depuis la CLI afin d’obtenir un jeton API valide pour interroger la base de données des vulnérabilités :

snyk auth

Une fois ces étapes terminées, nous pouvons créer une image Docker locale de l’application Python avec l’image de base python:3.8 :

❯ docker build . -t python-flask-app
FROM python:3.8 as build
[+] Building 5.2s (8/13)
 => [internal] load build definition from Dockerfile
 => => transferring dockerfile: 513B
 => [internal] load .dockerignore
 => => transferring context: 2B
 => [internal] load metadata for docker.io/library/python:3.8
 => [auth] library/python:pull token for registry-1.docker.io
 => [internal] load build context
...

Nous pouvons maintenant lancer l’analyse avec Snyk en exécutant :

snyk container test python-flask-app

Voici les résultats obtenus (volontairement abrégés, car la sortie est longue) :

Testing python-flask-app...

✗ Low severity vulnerability found in tiff/libtiff5
  Description: Out-of-bounds Read
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-TIFF-514595
  Introduced through: imagemagick@8:6.9.11.60+dfsg-1.3, imagemagick/libmagickcore-dev@8:6.9.11.60+dfsg-1.3
  From: imagemagick@8:6.9.11.60+dfsg-1.3 > imagemagick/imagemagick-6.q16@8:6.9.11.60+dfsg-1.3 > imagemagick/libmagickcore-6.q16-6@8:6.9.11.60+dfsg-1.3 > tiff/libtiff5@4.2.0-1
  From: imagemagick/libmagickcore-dev@8:6.9.11.60+dfsg-1.3 > imagemagick/libmagickcore-6.q16-dev@8:6.9.11.60+dfsg-1.3 > tiff/libtiff-dev@4.2.0-1 > tiff/libtiff5@4.2.0-1
  From: imagemagick/libmagickcore-dev@8:6.9.11.60+dfsg-1.3 > imagemagick/libmagickcore-6.q16-dev@8:6.9.11.60+dfsg-1.3 > tiff/libtiff-dev@4.2.0-1 > tiff/libtiffxx5@4.2.0-1 > tiff/libtiff5@4.2.0-1
  and 3 more...

✗ High severity vulnerability found in imagemagick/imagemagick-6-common
  Description: Information Exposure
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-IMAGEMAGICK-1246513
  Introduced through: imagemagick/libmagickcore-dev@8:6.9.11.60+dfsg-1.3, imagemagick/libmagickwand-dev@8:6.9.11.60+dfsg-1.3, imagemagick@8:6.9.11.60+dfsg-1.3
  From: imagemagick/libmagickcore-dev@8:6.9.11.60+dfsg-1.3 > imagemagick/imagemagick-6-common@8:6.9.11.60+dfsg-1.3
  From: imagemagick/libmagickwand-dev@8:6.9.11.60+dfsg-1.3 > imagemagick/imagemagick-6-common@8:6.9.11.60+dfsg-1.3
  From: imagemagick@8:6.9.11.60+dfsg-1.3 > imagemagick/imagemagick-6.q16@8:6.9.11.60+dfsg-1.3 > imagemagick/libmagickcore-6.q16-6@8:6.9.11.60+dfsg-1.3 > imagemagick/imagemagick-6-common@8:6.9.11.60+dfsg-1.3
  and 24 more...

✗ Critical severity vulnerability found in python3.9/libpython3.9-stdlib
  Description: Improper Input Validation
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-PYTHON39-1290158
  Introduced through: mercurial@5.6.1-4
  From: mercurial@5.6.1-4 > python3-defaults/python3@3.9.2-3 > python3-defaults/libpython3-stdlib@3.9.2-3 > python3.9/libpython3.9-stdlib@3.9.2-1
  From: mercurial@5.6.1-4 > python3-defaults/python3@3.9.2-3 > python3.9@3.9.2-1 > python3.9/libpython3.9-stdlib@3.9.2-1
  From: mercurial@5.6.1-4 > python3-defaults/python3@3.9.2-3 > python3-defaults/python3-minimal@3.9.2-3 > python3.9/python3.9-minimal@3.9.2-1
  and 4 more...

✗ Critical severity vulnerability found in glibc/libc-bin
  Description: Use After Free
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-GLIBC-1296898
  Introduced through: glibc/libc-bin@2.31-13+deb11u2, meta-common-packages@meta
  From: glibc/libc-bin@2.31-13+deb11u2
  From: meta-common-packages@meta > glibc/libc-dev-bin@2.31-13+deb11u2
  From: meta-common-packages@meta > glibc/libc6@2.31-13+deb11u2
  and 1 more...

Organization:      snyk-demo-567
Package manager:   deb
Project name:      docker-image|python-flask-app
Docker image:      python-flask-app
Platform:          linux/amd64
Base image:        python:3.8.12-bullseye
Licenses:          enabled

Tested 427 dependencies for known issues, found 171 issues.

Base Image              Vulnerabilities  Severity
python:3.8.12-bullseye  171              6 critical, 6 high, 27 medium, 132 low

Recommendations for base image upgrade:

Alternative image types
Base Image                   Vulnerabilities  Severity
python:3.9-slim              37               1 critical, 0 high, 1 medium, 35 low
python:3.11-rc-slim          37               1 critical, 0 high, 1 medium, 35 low
python:3.8.12-slim-bullseye  37               1 critical, 0 high, 1 medium, 35 low
python:3.10-slim-buster      70               2 critical, 9 high, 9 medium, 50 low

Nous avons donc 427 dépendances provenant de bibliothèques open source incluses dans le système d’exploitation Python 3.8. Elles exposent cette application Python Flask à un total de 171 vulnérabilités de sécurité, en raison du choix de l’image de base python:3.8.

À ce stade, vous vous demandez peut-être : « Comment corriger cela ? » Heureusement, Snyk recommande d’autres images de base vers lesquelles effectuer une mise à niveau ou une migration complète, afin de réduire la surface d’attaque.

Voici une capture d’écran qui illustre clairement les recommandations concernant cette image de base :

La sortie du terminal signale 171 vulnérabilités dans python:3.8.12-bullseye et compare leur nombre dans d’autres images de base Python.

Nous pouvons maintenant prendre une décision éclairée, fondée sur les données, pour sécuriser notre application Python. En choisissant l’une des autres images Docker recommandées par Snyk, nous pouvons réduire considérablement la surface d’attaque des logiciels intégrés à notre application.

Pour mieux maîtriser la sécurité de votre application, connectez vos dépôts à l’interface Snyk afin d’importer votre code source et votre Dockerfile. Vous pourrez ainsi détecter ces vulnérabilités et surveiller en continu l’apparition de nouveaux problèmes de sécurité. Voici le même rapport sur l’image Docker de base, cette fois dans l’interface Snyk :

Détails de l’image Snyk Container indiquant Python 3.8 sur Debian 11 et recommandant la mise à niveau de l’image de base, avec le nombre de vulnérabilités.

Mieux que de détecter et surveiller les vulnérabilités ? Les corriger ! :-)

Si vous connectez vos dépôts Git à Snyk, nous pouvons également créer automatiquement des pull requests dans votre dépôt pour vous proposer des mises à niveau de l’image Docker de base, comme ici :

Demande de pull GitHub montrant la mise à jour du Dockerfile d’un panier, de node:10 à node:debian-buster-slim, pour corriger des vulnérabilités

Si le sujet vous intéresse, découvrez cet article complémentaire sur l’automatisation de la sécurité des conteneurs grâce aux pull requests de Dockerfile.

Comment conteneuriser des applications Python ?

Docker est une technologie de virtualisation logicielle qui permet de créer des logiciels réutilisables, multiplateformes et rapides à déployer, sous la forme d’applications Python conteneurisées basées sur des images Docker Python. Ces applications sont définies sous forme d’infrastructure as code dans un fichier appelé Dockerfile.

Pour créer et utiliser une application Python conteneurisée, exécutez la commande suivante :

docker build -t flask-application .
docker run -p 8080:5000 flask-application

Recommandations de sécurité Python pour les développeurs

Ces bonnes pratiques devraient vous aider à mieux créer, gérer et sécuriser vos applications Python conteneurisées. Si vous avez apprécié cet article et que vous accordez de l’importance à la sécurité des applications et à sa promotion, nous vous recommandons les ressources suivantes pour approfondir le sujet :

La sécurité des conteneurs, pensée pour les développeurs

Snyk détecte et corrige automatiquement les vulnérabilités dans les images de conteneurs et les workloads Kubernetes.