Analyse de code offensif — Atelier pratique
À vous. Vous construisez un dépôt Flask volontairement vulnérable, vous le lisez, vous le passez à Semgrep, Bandit et Gitleaks, vous livrez cinq ou six constats, vous corrigez, vous re-scannez. La démonstration a montré comment lire une sortie. Ici, c'est le livrable qui compte.
Compter : 2 à 3 heures.
Livrable : un dossier ~/analyse-code/flask-audit/ avec le source, les sorties brutes, un rapport.md de six constats, et un correctif vérifié par un second scan.
Le seul code que vous auditez est celui que vous venez de créer dans ce dossier, ou un dépôt de lab fourni par le formateur. Pas de dépôt GitHub d'inconnu, pas de clone d'exploit public, pas d'application tierce en ligne. Vous ne poussez pas ce lab sur un dépôt public : il contient des motifs de secrets, même factices, et des routes dangereuses.
Étape 1 — Préparer le laboratoire (15 min)
mkdir -p ~/analyse-code/flask-audit/{sorties,correctifs}
cd ~/analyse-code/flask-audit
Créez quatre fichiers. Recopiez-les tels quels : les identifiants d'outils de la grille d'auto-évaluation s'appuient sur ces lignes.
config.py :
# Laboratoire local — ne pas déployer, ne pas pousser.
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
SLACK_WEBHOOK = "https://hooks.slack.com/services/T00000000/B00000000/exemplecours"
DB_PASSWORD = "SuperSecret123!"
utils.py :
import hashlib
import pickle
def hash_mot_de_passe(mot: str) -> str:
return hashlib.md5(mot.encode("utf-8")).hexdigest()
def charger_session(blob: bytes):
return pickle.loads(blob)
app.py :
import os
import sqlite3
from flask import Flask, request, render_template_string
import config
from utils import hash_mot_de_passe, charger_session
app = Flask(__name__)
@app.route("/user")
def user():
user_id = request.args.get("id", "1")
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = %s" % (user_id,))
return str(cur.fetchone())
@app.route("/ping")
def ping():
host = request.args.get("host", "127.0.0.1")
os.system("ping -c 1 %s" % host)
return "pong"
@app.route("/hello")
def hello():
name = request.args.get("name", "monde")
return render_template_string("<h1>Bonjour " + name + "</h1>")
@app.route("/session", methods=["POST"])
def session_charge():
return str(charger_session(request.data))
@app.route("/login", methods=["POST"])
def login():
password = request.form.get("password", "")
attendu = hash_mot_de_passe(config.DB_PASSWORD)
fourni = hash_mot_de_passe(password)
return "ok" if fourni == attendu else "ko"
requirements.txt :
Flask==3.0.3
Vous n'installez Flask que si vous voulez faire tourner l'appli en local après correctif. Pour l'audit SAST, les analyseurs suffisent.
Dans rapport.md, écrivez dès maintenant l'en-tête. Sans ça, le reste n'a pas de cadre.
# Revue statique — flask-audit
- Date :
- Périmètre : fichiers `app.py`, `utils.py`, `config.py` (lab local)
- Outils : Bandit, Semgrep (`p/python`, `p/flask`, `p/secrets`), Gitleaks `--no-git`
- Décision d'exécution : aucune charge envoyée, aucun `flask run` avant la fin des constats
Étape 2 — Lecture statique à la main (20 min)
Avant les outils. Ouvrez les trois fichiers Python et remplissez, dans sorties/lecture-manuelle.md, ce tableau.
| Fichier | Ligne | Primitive ou signal | Famille |
|---|---|---|---|
config.py | secret en dur | ||
utils.py | MD5 / pickle | ||
app.py | SQLi / commande / XSS / flux |
Objectif : six lignes minimum, une par famille exigée (SQLi, injection de commande, pickle, MD5, secret, XSS). Si vous n'avez pas les six avant Bandit, relisez : elles sont toutes visibles sans outil.
Cherchez aussi ce que les outils rateront peut-être : le webhook Slack est une URL en dur (signal du cours), pas seulement un « secret AWS ».
rg -n "eval|exec|system|pickle|md5|AKIA|authorized_keys|render_template_string|execute" .
Rangez la sortie dans sorties/rg.txt. C'est votre filet, pas votre rapport.
Étape 3 — Lancer les trois analyseurs (20 min)
cd ~/analyse-code/flask-audit
bandit -r . -f txt -o sorties/bandit.txt
bandit -r . -f json -o sorties/bandit.json
semgrep --config p/python --config p/flask --config p/secrets \
--json --output sorties/semgrep.json .
semgrep --config p/python --config p/flask --config p/secrets \
--text --output sorties/semgrep.txt .
gitleaks detect --source . --no-git -v --report-path sorties/gitleaks.json \
| tee sorties/gitleaks.txt
Si Semgrep refuse une config, documentez le repli dans le rapport (--config auto ou règles locales) et continuez. Un outil manquant se remplace par rg plus la lecture ; il ne se remplace pas par l'exécution des routes.
Vous devez pouvoir montrer, pour chaque outil, un fichier non vide dans sorties/. C'est le premier critère de recevabilité.
Étape 4 — Recouper, ne pas recopier (25 min)
Ouvrez les trois sorties. Construisez sorties/matrice.md :
| ID interne | Ligne | Bandit | Semgrep | Gitleaks | Lecture manuelle | Verdict |
| --- | --- | --- | --- | --- | --- | --- |
| A-01 | | | | | | retenir / bruit / exemple |
Règles de verdict :
- Retenir : la ligne fait ce que l'outil prétend (donnée entrante dans
execute,os.system,pickle.loads, HTML concaténé, hash de mot de passe en MD5, secret dans le source). - Exemple :
AKIAIOSFODNN7EXAMPLEet le secret AWS de la doc. Vous le retenez quand même comme constat de motif (le dépôt enseigne à stocker des clés dansconfig.py), en qualifiant « clé de documentation ». - Bruit : une alerte sur une constante sans flux entrant, ou un doublon exact. Un seul constat par faille, même si trois outils parlent.
Vous livrez cinq ou six constats retenus, pas trente copies d'écran. Les six familles du sujet doivent apparaître. Si Bandit oublie la XSS, Semgrep ou votre lecture manuelle la porte.
login appelle deux fois hash_mot_de_passe. C'est un constat MD5 (la primitive dans utils.py), pas deux. La route /session et charger_session sont un constat pickle. Le client corrige un endroit, pas une alerte par outil.
Étape 5 — Rédiger les six constats (40 min)
Chaque constat du rapport.md suit le même squelette. Recopiez le gabarit six fois (A-01 à A-06). Les blocs de code intérieurs restent à trois accents graves ; le gabarit extérieur est donné ici avec quatre pour que la page compile — dans votre rapport.md (Markdown simple), trois suffisent partout.
## A-0X — `<famille>` : `<titre en une ligne>`
- **Fichier / ligne** :
- **Outils** : (ex. Bandit B608, Semgrep tainted-sql-string)
- **Prérequis** : qui peut joindre quoi, sans lancer d'exploit
- **Preuve** (extrait) :
```python
# collez UNIQUEMENT la ligne fautive, pas tout le fichier
```
- **Impact** : une à trois phrases, privilège et conséquence
- **Remédiation** : l'action, pas « sécuriser le code »
- **Revérification** : commande et critère (l'ID d'outil disparaît, ou la ligne n'existe plus)
Ce que chaque famille doit démontrer
Vous n'envoyez aucune charge HTTP. La preuve est le source.
| ID suggéré | Famille | Preuve minimale | Impact à formuler | Remédiation attendue |
|---|---|---|---|---|
| A-01 | SQLi | cur.execute formaté avec user_id issu de request.args | Requête SQL contrôlée par le client | Requête paramétrée ? / liaison |
| A-02 | Commande | os.system + host | Commande système contrôlée | subprocess en liste, sans shell, hôte validé (allowlist) |
| A-03 | XSS | render_template_string + concaténation de name | HTML reflété, script possible dans le navigateur | Gabarit Jinja auto-échappé, pas de concaténation |
| A-04 | Pickle | pickle.loads sur request.data | Exécution de code au chargement | json + schéma, ou suppression de la route |
| A-05 | MD5 | hashlib.md5 sur un mot de passe | Empreinte cassable, pas un stockage de passe | argon2 / bcrypt, jamais MD5 ni SHA-1 pour un secret |
| A-06 | Secret | clés dans config.py | Fuite dès le partage du dossier | Variables d'environnement, fichier hors dépôt, clés d'exemple retirées |
Exemple rédigé pour A-04 — les cinq autres sont à vous, au même niveau de précision.
## A-04 — Désérialisation : `pickle.loads` sur `POST /session`
- **Fichier / ligne** : `utils.py` (`charger_session`) appelée par `app.py` (`session_charge`)
- **Outils** : Bandit B301, Semgrep `python.lang.security.deserialization.pickle.avoid-pickle`
- **Prérequis** : joindre `POST /session` (route anonyme dans ce lab)
- **Preuve** :
```python
def charger_session(blob: bytes):
return pickle.loads(blob)
```
- **Impact** : le corps de la requête est désérialisé. Un objet pickle n'est pas une donnée : c'est un programme. Le processus Flask s'exécute avec les droits du compte qui le lance.
- **Remédiation** : supprimer `charger_session` et la route, ou n'accepter qu'un JSON borné (`json.loads` + clés attendues).
- **Revérification** : `rg -n pickle utils.py app.py` sans correspondance ; Bandit ne cite plus B301.
Étape 6 — Un correctif, puis le re-scan (30 min)
Choisissez au moins deux constats parmi A-01, A-02, A-04 (les trois qui sont des exécutions de code). Les secrets (A-06) comptent aussi : extraire vers l'environnement est un correctif visible par Gitleaks.
Travaillez dans des copies, pas en écrasant sans filet :
cp app.py correctifs/app.py.avant
cp utils.py correctifs/utils.py.avant
cp config.py correctifs/config.py.avant
Correctifs minimaux acceptés (à adapter, pas à coller aveuglément si vos lignes ont bougé) :
# A-01 — app.py
cur.execute("SELECT * FROM users WHERE id = ?", (user_id,))
# A-02 — app.py
import subprocess
subprocess.run(["ping", "-c", "1", "--", host], check=False)
# et, avant l'appel : refuser tout host qui n'est pas dans une allowlist d'IP/noms
# A-03 — app.py
from flask import render_template
# gabarit templates/hello.html : <h1>Bonjour {{ name }}</h1>
return render_template("hello.html", name=name)
# A-04 — utils.py + app.py
import json
def charger_session(blob: bytes):
return json.loads(blob.decode("utf-8"))
# A-05 — utils.py
from hashlib import sha256 # insuffisant pour un mot de passe réel
# attendu dans le rapport : bcrypt ou argon2, pas un autre MD
# exemple de direction :
# import bcrypt
# return bcrypt.hashpw(mot.encode(), bcrypt.gensalt()).decode()
# A-06 — config.py
import os
AWS_ACCESS_KEY_ID = os.environ["AWS_ACCESS_KEY_ID"]
AWS_SECRET_ACCESS_KEY = os.environ["AWS_SECRET_ACCESS_KEY"]
DB_PASSWORD = os.environ["DB_PASSWORD"]
Pour A-03, le gabarit Jinja échappe par défaut : une interpolation {{ name }} n'est plus une concaténation HTML. Créez templates/hello.html si vous corrigez la XSS.
Relancez les mêmes commandes, vers des fichiers distincts :
bandit -r . -f txt -o sorties/bandit-apres.txt
semgrep --config p/python --config p/flask --config p/secrets \
--text --output sorties/semgrep-apres.txt .
gitleaks detect --source . --no-git -v --report-path sorties/gitleaks-apres.json \
| tee sorties/gitleaks-apres.txt
Dans rapport.md, une section Revérification :
## Revérification
| ID | Avant (ID outil ou ligne) | Après | Statut |
| --- | --- | --- | --- |
| A-01 | B608 / execute % | absent de bandit-apres.txt | corrigé / partiel / échec |
| A-02 | | | |
| A-04 | | | |
| A-06 | AKIA dans gitleaks.json | | |
« Partiel » est honnête : subprocess.run en liste ferme l'injection par shell, mais un host non validé reste un problème. Vous le notez. Un correctif qui fait taire Bandit en ajoutant # nosec sans changer le flux est un échec : mentionnez-le si vous l'avez tenté, puis retirez-le.
# nosec, nosemgrep et les exclusions Gitleaks ne sont acceptés que pour un faux positif argumenté dans le constat. Les six familles de cet atelier ne sont pas des faux positifs.
Étape 7 — Livrable vérifiable
L'arborescence finale, telle qu'un correcteur la rouvre :
~/analyse-code/flask-audit/
app.py
utils.py
config.py
requirements.txt
templates/hello.html # si A-03 corrigé
sorties/
lecture-manuelle.md
rg.txt
matrice.md
bandit.txt
bandit.json
semgrep.txt
semgrep.json
gitleaks.txt
gitleaks.json
bandit-apres.txt
semgrep-apres.txt
gitleaks-apres.txt
gitleaks-apres.json
correctifs/
app.py.avant
utils.py.avant
config.py.avant
rapport.md
rapport.md contient, dans cet ordre :
- L'en-tête de l'étape 1 (périmètre, outils, décision de ne pas exécuter de charge).
- La matrice de recoupement (ou un renvoi explicite à
sorties/matrice.md). - Cinq ou six constats au gabarit de l'étape 5, familles toutes présentes.
- La section Revérification avec au moins deux lignes au statut
corrigéoupartiel, preuves danssorties/*-apres.*. - Une phrase sur ce que vous n'avez pas fait : pas de
python app.pyavant la fin des constats, pas de push public, pas de charge.
Grille d'auto-évaluation
- Le périmètre tient en trois fichiers Python créés pour l'atelier (ou lab formateur nommé).
- Une lecture manuelle précède les outils, avec six familles repérées.
-
sorties/bandit.txt,sorties/semgrep.txtetsorties/gitleaks.txtexistent et ne sont pas vides. - La matrice recoupe les outils : pas un constat par alerte, un constat par faille.
- Six constats : SQLi, commande, XSS, pickle, MD5, secret — chacun avec preuve source, impact, remédiation, critère de re-scan.
- Les clés
AKIAIOSFODNN7EXAMPLEsont qualifiées d'exemple, pas vendues comme une fuite AWS réelle. - Au moins deux correctifs appliqués, copies
correctifs/*.avantconservées. - Un second scan montre la disparition (ou le statut partiel) des IDs concernés.
- Aucune charge n'a été envoyée à une machine tierce. Aucun exploit public n'a été lancé.
- Le dossier n'est pas sur un GitHub public.
Tout est coché ? Le livrable est recevable.
Ce qui bloque en général
- Semgrep échoue hors ligne. Utilisez les configs déjà téléchargées, ou Bandit +
rgen le déclarant. N'inventez pas une sortie. - Gitleaks ne voit rien. Vérifiez
--no-gitet queconfig.pyest bien dans le dossier courant. Ungitleaks protectsur un dépôt vide ne scanne pas les fichiers non suivis. - Bandit est silencieux sur la XSS. C'est attendu. La XSS est votre constat de lecture / Semgrep, pas un trou dans votre travail.
- Tentation de
curlles routes. L'atelier est une revue. Une confirmation DAST sort du sujet et, sur un Flask mal lancé, sort parfois du127.0.0.1. - Rapport = collage des JSON. Le correcteur lit
rapport.md. Les JSON sont des annexes.
Ce que vous emportez de cet atelier
- Un enchaînement reproductible : lecture, trois outils, matrice, constats, correctif, re-scan.
- La preuve qu'un constat de code se tient sans envoyer de charge.
- Le réflexe de qualifier un secret d'exemple plutôt que d'alarmer pour la clé de la documentation AWS.
- Un dossier que l'on peut rouvrir dans six mois et revérifier.