Aller au contenu principal

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.

Cadre strict

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.

FichierLignePrimitive ou signalFamille
config.pysecret en dur
utils.pyMD5 / pickle
app.pySQLi / 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 : AKIAIOSFODNN7EXAMPLE et 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 dans config.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.

Un constat, une faille

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éFamillePreuve minimaleImpact à formulerRemédiation attendue
A-01SQLicur.execute formaté avec user_id issu de request.argsRequête SQL contrôlée par le clientRequête paramétrée ? / liaison
A-02Commandeos.system + hostCommande système contrôléesubprocess en liste, sans shell, hôte validé (allowlist)
A-03XSSrender_template_string + concaténation de nameHTML reflété, script possible dans le navigateurGabarit Jinja auto-échappé, pas de concaténation
A-04Picklepickle.loads sur request.dataExécution de code au chargementjson + schéma, ou suppression de la route
A-05MD5hashlib.md5 sur un mot de passeEmpreinte cassable, pas un stockage de passeargon2 / bcrypt, jamais MD5 ni SHA-1 pour un secret
A-06Secretclés dans config.pyFuite dès le partage du dossierVariables 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.

Ne pas « corriger » en muselant l'outil

# 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 :

  1. L'en-tête de l'étape 1 (périmètre, outils, décision de ne pas exécuter de charge).
  2. La matrice de recoupement (ou un renvoi explicite à sorties/matrice.md).
  3. Cinq ou six constats au gabarit de l'étape 5, familles toutes présentes.
  4. La section Revérification avec au moins deux lignes au statut corrigé ou partiel, preuves dans sorties/*-apres.*.
  5. Une phrase sur ce que vous n'avez pas fait : pas de python app.py avant 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.txt et sorties/gitleaks.txt existent 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 AKIAIOSFODNN7EXAMPLE sont qualifiées d'exemple, pas vendues comme une fuite AWS réelle.
  • Au moins deux correctifs appliqués, copies correctifs/*.avant conservé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 + rg en le déclarant. N'inventez pas une sortie.
  • Gitleaks ne voit rien. Vérifiez --no-git et que config.py est bien dans le dossier courant. Un gitleaks protect sur 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 curl les routes. L'atelier est une revue. Une confirmation DAST sort du sujet et, sur un Flask mal lancé, sort parfois du 127.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.