fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous

This commit is contained in:
Dorian
2026-09-22 14:08:51 +02:00
parent d9103ee4ed
commit e220f8f0c6
8 changed files with 267 additions and 145 deletions
+151 -74
View File
@@ -1,12 +1,14 @@
name: DAST name: DAST
# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre # Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre
# la base et le backend sur le runner, crée un compte `lecteur` jetable (scripts/dast-token.sh), # la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne
# puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte. # frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable
# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte.
# #
# Non bloquant pour l'instant : le volume d'alertes d'un premier passage est inconnu. Le rapport # Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) :
# est publié en artefact et dans le résumé du job. Le fixer en seuil viendra une fois les alertes # le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
# triées. # scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit
# vouloir dire qu'un scan a eu lieu.
# #
# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas # Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et # de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
@@ -34,7 +36,10 @@ jobs:
zap: zap:
name: Scan OWASP ZAP de l'API name: Scan OWASP ZAP de l'API
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 # Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est
# `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par
# ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre.
timeout-minutes: 30
# Même image que docker-compose.yml : la première migration refuse de s'appliquer sans # Même image que docker-compose.yml : la première migration refuse de s'appliquer sans
# l'extension TimescaleDB (cf. backend.yml). # l'extension TimescaleDB (cf. backend.yml).
@@ -60,24 +65,27 @@ jobs:
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
APP_ENV: local APP_ENV: local
# Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut). # Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut).
# 3600 est le plafond accepté par la configuration. # 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins`
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
APP_ACCESS_TOKEN_TTL_SECONDS: "3600" APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
PGPASSWORD: change_me PGPASSWORD: change_me
steps: steps:
- name: Récupère le dépôt - name: Récupère le dépôt
uses: actions/checkout@v4 uses: actions/checkout@v7
- name: Installe uv - name: Installe uv
# Épinglé sur le commit du tag v5 (règle Sonar githubactions:S7637). # Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 # contrairement à actions/checkout ou actions/upload-artifact, premières parties).
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with: with:
enable-cache: true enable-cache: true
cache-dependency-glob: apps/backend/uv.lock cache-dependency-glob: apps/backend/uv.lock
# `prune-cache` vaut `true` par défaut sur ce commit : l'étape de post-job « Pruning # `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
# cache » est restée bloquée 5 minutes avant d'échouer (exit code 2), sans lien avec le # « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
# scan lui-même (les 16 étapes précédentes passaient). Le prune n'est qu'une optimisation # run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
# de taille de cache, pas une garantie : le désactiver ici retire le blocage. # qu'une optimisation de taille de cache entre deux runs, pas une garantie : le
# désactiver retire le blocage sans rien changer au comportement du job.
prune-cache: false prune-cache: false
- name: Installe l'interpréteur déclaré par .python-version - name: Installe l'interpréteur déclaré par .python-version
@@ -99,6 +107,24 @@ jobs:
run: uv run --frozen --no-sync --no-build alembic upgrade head run: uv run --frozen --no-sync --no-build alembic upgrade head
working-directory: apps/backend working-directory: apps/backend
# Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne
# frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide
# (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main,
# juste assez pour que les routes de lecture aient quelque chose à rendre.
- name: Insère un site et des relevés minimaux pour le scan
run: |
psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL'
INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status)
VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif')
ON CONFLICT (site_id) DO NOTHING;
INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data)
VALUES
('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'),
('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}')
ON CONFLICT DO NOTHING;
SQL
- name: Démarre l'API - name: Démarre l'API
run: | run: |
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \ nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
@@ -120,108 +146,157 @@ jobs:
echo "jeton=$jeton" >> "$GITHUB_OUTPUT" echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
working-directory: apps/backend working-directory: apps/backend
# `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie # Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
# appartient à l'utilisateur du conteneur (uid 1000), sans droits d'écriture pour les autres # (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
# (règle Sonar S2612 : pas de `chmod 777`). # pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante.
- name: Prépare le contrat pour ZAP
run: |
mkdir -p zap-out zap-logs
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json
# Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit
# plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous.
# Pas de `chmod 777` (règle Sonar S2612).
sudo chown -R 1000:1000 zap-out
# `--network host` : ZAP atteint l'API sur le localhost du runner.
# #
# Piège : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte n'importe quelle # Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
# clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les requêtes un en-tête au # n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
# nom vide (`: Bearer <jeton>`), que uvicorn refuse par un 400 (« Invalid HTTP request # requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
# received »), y compris sur les routes publiques. # (« Invalid HTTP request received »), y compris sur les routes publiques.
#
# Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de
# commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par
# l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le
# conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en
# lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié.
# #
# Les routes d'authentification qui changent l'état du compte du scan sont exclues : un # Les routes d'authentification qui changent l'état du compte du scan sont exclues : un
# scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de # scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de
# passe et la fermeture des sessions, sans rien apprendre de plus. # passe et la fermeture des sessions, sans rien apprendre de plus.
# #
# Le conteneur n'est pas jetable (`--rm`) : son journal interne (`zap.log`) est copié en # `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
# sortie, c'est lui qui dit pourquoi un import OpenAPI a échoué. # couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui
# traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui
# annule sans exécuter les étapes `always()`, rapport et journaux perdus).
- name: Scan ZAP - name: Scan ZAP
id: zap id: zap
continue-on-error: true continue-on-error: true
env:
JETON: ${{ steps.jeton.outputs.jeton }}
run: | run: |
set -o pipefail set -o pipefail
mkdir -p zap-out zap-logs printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf"
# Le contrat est chargé depuis un fichier : `importUrl` a répondu 400 au premier passage chmod 600 "$RUNNER_TEMP/zap-auth.conf"
# et ZAP est alors reparti explorer la racine (2 URL, que des 404). docker run --name zap --network host \
curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json -v "$PWD/zap-out:/zap/wrk:rw" \
# Les noms des schémas de sécurité du contrat sont accentués (`Jeton d'accès`...) : ZAP -v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
# les analyse mal. Seule la copie donnée à ZAP est renommée, le contrat versionné reste
# tel quel.
python3 - <<'PY'
import json, pathlib
chemin = pathlib.Path("zap-out/openapi.json")
contrat = chemin.read_text(encoding="utf-8")
for ancien, nouveau in (("Jeton d'accès", "JetonAcces"), ("Cookie de rafraîchissement", "CookieRafraichissement")):
contrat = contrat.replace(ancien, nouveau)
json.loads(contrat)
chemin.write_text(contrat, encoding="utf-8")
PY
sudo chown -R 1000:1000 zap-out
docker run --name zap --network host -v "$PWD/zap-out:/zap/wrk:rw" \
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \ ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \ -t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
-T 30 -d \ -T 10 \
-r zap-report.html -J zap-report.json -w zap-report.md \ -r zap-report.html -J zap-report.json -w zap-report.md \
-z "-config replacer.full_list(0).description=auth \ -z "-configfile /zap/auth.conf \
-config replacer.full_list(0).enabled=true \
-config replacer.full_list(0).matchtype=REQ_HEADER \
-config replacer.full_list(0).matchstr=Authorization \
-config replacer.full_list(0).regex=false \
-config replacer.full_list(0).replacement='Bearer ${{ steps.jeton.outputs.jeton }}' \
-config globalexcludeurl.url_list.url(0).description=auth-etat \ -config globalexcludeurl.url_list.url(0).description=auth-etat \
-config globalexcludeurl.url_list.url(0).enabled=true \ -config globalexcludeurl.url_list.url(0).enabled=true \
-config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*'" \ -config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \
-config scanner.maxScanDurationInMins=15 \
-config scanner.maxRuleDurationInMins=5" \
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log" 2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
- name: Récupère les journaux de ZAP - name: Récupère les journaux de ZAP
if: always() if: always()
run: | run: |
mkdir -p zap-logs mkdir -p zap-logs
cp "$RUNNER_TEMP/zap-stdout.log" zap-logs/zap-stdout.log || true # ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
docker cp zap:/home/zap/.ZAP/zap.log zap-logs/zap.log || true # à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
cp "$RUNNER_TEMP/api.log" zap-logs/api.log || true # même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job.
masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; }
[ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log
docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true
[ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log
[ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log
rm -f "$RUNNER_TEMP/zap-auth.conf"
docker rm -f zap >/dev/null 2>&1 || true docker rm -f zap >/dev/null 2>&1 || true
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien # `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
# testé : ZAP « réussit » aussi quand l'import du contrat n'a chargé que quelques URL, et # testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
# ne teste alors que des 404 (constaté au premier passage : 2 URL importées sur 26 # que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
# opérations). Les alertes restent non bloquantes, ce garde-fou-là bloque. # : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était.
- name: Vérifie que le contrat a bien été importé - name: Vérifie que le contrat a bien été importé
run: | run: |
attendu="$(python3 -c "
import json
d = json.load(open('zap-out/openapi.json'))
methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options')
print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes))
")"
minimum=$((attendu * 80 / 100))
importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)" importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)"
minimum=10 echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune}"
if [ "${importees:-0}" -lt "$minimum" ]; then if [ "${importees:-0}" -lt "$minimum" ]; then
echo "::error::ZAP n'a importé que ${importees:-0} URL du contrat OpenAPI (minimum attendu : $minimum). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report." echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report."
exit 1 exit 1
fi fi
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des 4xx (constaté au # Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
# deuxième passage : 81 endpoints, 100 % de 400, job vert). Un scan dont toutes les réponses # (constaté : base sans données, toutes les routes de site répondaient 404).
# sont des erreurs client n'a rien testé de l'API. #
- name: Vérifie que l'API a répondu autre chose que des erreurs client # Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le
# pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre
# d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan
# sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx :
# c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue
# vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier
# incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès,
# pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt
# que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus
# d'être fragile au format.
- name: Vérifie que le scan a obtenu au moins une réponse de succès
run: | run: |
if [ ! -f zap-out/zap-report.md ]; then python3 - <<'PY'
echo "::error::Aucun rapport ZAP produit : le scan n'a rien testé." import json
exit 1 import sys
fi
if grep -q "status code 4xx | 100 %" zap-out/zap-report.md; then
echo "::error::100 % des réponses sont des erreurs client (4xx) : le scan n'a rien testé de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact zap-report."
exit 1
fi
try:
rapport = json.load(open("zap-out/zap-report.json"))
except FileNotFoundError:
print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.")
sys.exit(1)
pourcentage_2xx = 0.0
for insight in rapport.get("insights", []):
if insight.get("key") == "insight.code.2xx":
pourcentage_2xx = float(insight.get("statistic", 0))
break
print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%")
if pourcentage_2xx <= 0:
print(
"::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route "
"réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact "
"zap-report."
)
sys.exit(1)
PY
# Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est
# limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job
# après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport
# complet reste dans l'artefact `zap-report`.
- name: Publie le résumé - name: Publie le résumé
if: always() if: always()
run: | run: |
if [ -f zap-out/zap-report.md ]; then if [ -f zap-out/zap-report.md ]; then
cat zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY" awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY"
else else
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY" echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
fi fi
- name: Publie les rapports - name: Publie les rapports
if: always() if: always()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: zap-report name: zap-report
path: | path: |
@@ -229,8 +304,10 @@ jobs:
zap-logs/ zap-logs/
if-no-files-found: warn if-no-files-found: warn
# Un scan sans compte authentifié ne testerait que les routes publiques : mieux vaut le # Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
# dire que le laisser passer pour vert. # (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les
# afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec
# évident (l'API n'a jamais démarré, par exemple).
- name: Journal de l'API en cas d'échec - name: Journal de l'API en cas d'échec
if: failure() || steps.zap.outcome == 'failure' if: failure() || steps.zap.outcome == 'failure'
run: cat "$RUNNER_TEMP/api.log" || true run: cat "$RUNNER_TEMP/api.log" || true
+3 -1
View File
@@ -48,7 +48,9 @@ SettingsDep = Annotated[Settings, Depends(get_settings)]
CODE_CHANGEMENT_REQUIS = "password_change_required" CODE_CHANGEMENT_REQUIS = "password_change_required"
_porteur = HTTPBearer(auto_error=False, scheme_name="Jeton d'accès") # Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un nom
# de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
_porteur = HTTPBearer(auto_error=False, scheme_name="JetonAcces")
CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)] CredentialsDep = Annotated[HTTPAuthorizationCredentials | None, Depends(_porteur)]
+3 -1
View File
@@ -94,7 +94,9 @@ TAGS: Final[list[dict[str, Any]]] = [
cookie_de_rafraichissement = APIKeyCookie( cookie_de_rafraichissement = APIKeyCookie(
name=REFRESH_COOKIE_DEFAUT, name=REFRESH_COOKIE_DEFAUT,
scheme_name="Cookie de rafraîchissement", # Nom ASCII : un outillage tiers (ZAP, cf. .github/workflows/dast.yml) peut mal analyser un
# nom de schéma accentué dans le contrat OpenAPI. Piège vécu, pas anticipé.
scheme_name="CookieRafraichissement",
description=( description=(
"Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le " "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le "
"préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers " "préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers "
+22 -22
View File
@@ -213,7 +213,7 @@
}, },
"security": [ "security": [
{ {
"Cookie de rafraîchissement": [] "CookieRafraichissement": []
} }
] ]
} }
@@ -252,7 +252,7 @@
}, },
"security": [ "security": [
{ {
"Cookie de rafraîchissement": [] "CookieRafraichissement": []
} }
] ]
} }
@@ -301,7 +301,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -347,7 +347,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -423,7 +423,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -673,7 +673,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
}, },
@@ -757,7 +757,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -771,7 +771,7 @@
"operationId": "update_user_api_v1_users__user_id__patch", "operationId": "update_user_api_v1_users__user_id__patch",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -889,7 +889,7 @@
"operationId": "reset_password_api_v1_users__user_id__password_reset_post", "operationId": "reset_password_api_v1_users__user_id__password_reset_post",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1023,7 +1023,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1037,7 +1037,7 @@
"operationId": "get_site_api_v1_sites__site_id__get", "operationId": "get_site_api_v1_sites__site_id__get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1124,7 +1124,7 @@
"operationId": "get_current_api_v1_sites__site_id__current_get", "operationId": "get_current_api_v1_sites__site_id__current_get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1211,7 +1211,7 @@
"operationId": "list_alerts_api_v1_alerts_get", "operationId": "list_alerts_api_v1_alerts_get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1361,7 +1361,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1375,7 +1375,7 @@
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get", "operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1462,7 +1462,7 @@
"operationId": "generate_recommendations_api_v1_recommendations_generate_post", "operationId": "generate_recommendations_api_v1_recommendations_generate_post",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1588,7 +1588,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1602,7 +1602,7 @@
"operationId": "list_readings_api_v1_readings_get", "operationId": "list_readings_api_v1_readings_get",
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
], ],
"parameters": [ "parameters": [
@@ -1799,7 +1799,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -1855,7 +1855,7 @@
}, },
"security": [ "security": [
{ {
"Jeton d'accès": [] "JetonAcces": []
} }
] ]
} }
@@ -3225,13 +3225,13 @@
} }
}, },
"securitySchemes": { "securitySchemes": {
"Cookie de rafraîchissement": { "CookieRafraichissement": {
"type": "apiKey", "type": "apiKey",
"description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.", "description": "Cookie `HttpOnly` posé par `/auth/login` et tourné par `/auth/refresh`. Il prend le préfixe `__Secure-` dès que l'API tourne derrière TLS, et n'est émis que vers `/api/v1/auth`.",
"in": "cookie", "in": "cookie",
"name": "ev_refresh" "name": "ev_refresh"
}, },
"Jeton d'accès": { "JetonAcces": {
"type": "http", "type": "http",
"scheme": "bearer" "scheme": "bearer"
} }
+2 -2
View File
@@ -104,8 +104,8 @@ def test_the_rate_limit_documents_the_delay_header(schema: dict[str, Any]) -> No
def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None: def test_the_refresh_cookie_appears_in_the_security_schemes(schema: dict[str, Any]) -> None:
schemes = schema["components"]["securitySchemes"] schemes = schema["components"]["securitySchemes"]
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie" assert schemes["CookieRafraichissement"]["in"] == "cookie"
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh" assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None: def test_each_tag_used_by_a_route_is_described(schema: dict[str, Any]) -> None:
+4 -2
View File
@@ -331,8 +331,10 @@ pas prise :
| `license_info` | Aucune licence n'est choisie | | `license_info` | Aucune licence n'est choisie |
| `contact` | Aucun canal de support n'existe | | `contact` | Aucun canal de support n'existe |
Deux schémas de sécurité sont déclarés : `Jeton d'accès` pour le porteur JWT, et Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement `CookieRafraichissement` pour `/auth/refresh` et `/auth/logout`, des noms ASCII délibérés (issue
#41 : un outillage tiers comme ZAP peut mal analyser un nom de schéma accentué dans le contrat).
**Le second est purement
documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai documentaire** : son `auto_error=False` garantit qu'il ne décide d'aucun refus. Le passer à vrai
ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le ferait répondre 403 avant d'atteindre `lit_le_cookie()`, et `/auth/refresh` cesserait de rendre le
401 sur lequel le frontend déclenche sa déconnexion. 401 sur lequel le frontend déclenche sa déconnexion.
+65 -33
View File
@@ -57,13 +57,21 @@ flowchart TB
push --> fd push --> fd
push --> mv & ms push --> mv & ms
push --> av & ab push --> av & ab
push --> sb1 & sb2 --> sscan push --> sb1 & sb2 & sb3 --> sscan
subgraph cd["Déploiement · deploy.yml"] subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"] dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end end
push -->|"push sur dev ou main"| dep push -->|"push sur dev ou main"| dep
planifie["chaque lundi 3h UTC,<br/>ou à la main"]
subgraph dastw["DAST · dast.yml"]
zscan["zap<br/>seed + scan actif OWASP ZAP"]
end
planifie --> zscan
push -->|"PR sur dast.yml<br/>ou dast-token.sh"| zscan
``` ```
## Déclenchement ## Déclenchement
@@ -97,7 +105,7 @@ environnement.
## Déploiement ## Déploiement
`deploy.yml` est le sixième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur `deploy.yml` est l'un des sept workflows, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
entrant n'est ouvert. entrant n'est ouvert.
@@ -250,11 +258,20 @@ UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan act
minutes. minutes.
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
jetable) et le backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
jeton. ZAP charge le contrat `/openapi.json` (`zap-api-scan.py -f openapi`) et envoie ce jeton encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
dans l'en-tête `Authorization`. Sans lui, ZAP ne verrait que les deux sondes et `/auth/login`. `/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le
backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton.
Trois décisions à savoir défendre : ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t
/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le
contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les
réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que
de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`,
`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et
répondent donc pareil avec ou sans lui.
Décisions à savoir défendre :
- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait - **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait
`POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin `POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin
@@ -262,42 +279,57 @@ Trois décisions à savoir défendre :
- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le - **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le
mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant
de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`. de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`.
`POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans
`app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert
directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux
allers-retours de refresh-token de moins sur le chemin critique de la CI.
- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut - **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut
dure 15 minutes et le scan bien plus. dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en
dessous, marge comprise pour les étapes qui l'entourent.
- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker
run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque
du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par
`docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de
configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne
jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque
`-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et
`zap-stdout.log` publiées en artefact sont donc caviardées avant publication.
Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`, Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`,
`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la `forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la
limitation de débit et fermeraient les sessions sans rien apprendre de plus. limitation de débit et fermeraient les sessions sans rien apprendre de plus.
**Un scan vert n'est pas un scan qui a testé quelque chose.** Au premier passage, le job était vert **Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** :
alors que ZAP n'avait importé que **2 URL sur 26 opérations** du contrat (`Number of Imported URLs:
2`) : il n'avait envoyé que des requêtes vouées au 404, sans jamais atteindre une route gardée
(rapport : 100 % de réponses 4xx, zéro alerte). ZAP « réussit » dans ce cas. Le job porte donc un
garde-fou qui, lui, **bloque** : il échoue si moins de 10 URL sont importées. Le journal interne de
ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans l'artefact `zap-report`
(dossier `zap-logs/`) pour diagnostiquer un import raté.
Diagnostic du premier passage : `zap-api-scan.py` appelle `importUrl` sur `/openapi.json`, ZAP répond - **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
**400**, le contrat n'est pas chargé et ZAP se rabat sur l'exploration de la racine. Le job charge opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
donc le contrat **depuis un fichier** (`-t /zap/wrk/openapi.json -O http://localhost:8000`) et refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
renomme dans cette copie, sans toucher au contrat versionné, les deux schémas de sécurité aux noms ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne. permissive qu'elle ne l'était.
Deuxième diagnostic (contrat importé, 81 endpoints) : **toutes** les requêtes de ZAP recevaient un 400 - **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, et du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
le job restait vert. Un dump des octets échangés (`socat -v`, retiré depuis) a montré la cause : ZAP pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
ajoutait à chaque requête une ligne d'en-tête au **nom vide**, `: Bearer <jeton>`. La clé de erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
configuration du nom d'en-tête pour la règle Replacer est **`matchstr`** ; le job écrivait y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
`matchstring` (nom utilisé par le job d'automatisation ZAP, pas par `-config`). ZAP accepte le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
n'importe quelle clé `-config` sans erreur, il a donc laissé le nom vide. Le job porte deux garde-fous d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
qui font échouer un scan qui n'a rien testé : moins de 10 URL importées, ou 100 % de réponses 4xx. seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un
scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul,
aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent
`zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport
Markdown.
Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans
plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède. l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid
1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour
diagnostiquer un futur import raté.
**Non bloquant pour l'instant** (`continue-on-error`) pour ce qui est des alertes. Le volume d'alertes d'un premier passage est **Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est
inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et dans le résumé du des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
job. Fixer un seuil viendra une fois les alertes triées. HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux
d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les
alertes triées.
**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`, **Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`,
pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy
+17 -10
View File
@@ -34,34 +34,41 @@ connexion() {
| jq -r '.access_token' | jq -r '.access_token'
} }
# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en
# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier
# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de
# refresh-token superflus sur le chemin critique de la CI.
changer_mot_de_passe() { changer_mot_de_passe() {
local jeton="$1" ancien="$2" nouveau="$3" local jeton="$1" ancien="$2" nouveau="$3"
curl -fsS -o /dev/null -X POST "$API/auth/password" \ curl -fsS -X POST "$API/auth/password" \
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \ -H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
-d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" -d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \
| jq -r '.access_token'
} }
journal "création de l'administrateur $EMAIL_ADMIN" journal "création de l'administrateur $EMAIL_ADMIN"
SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)" if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then
journal "la création de l'administrateur a échoué :"
journal "$SORTIE"
exit 1
fi
MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")" MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")"
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie"; exit 1; } [[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; }
JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")" JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")"
NOUVEAU_ADMIN="$(nouveau_mot_de_passe)" NOUVEAU_ADMIN="$(nouveau_mot_de_passe)"
changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN" JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")"
# Le changement de mot de passe ferme les sessions : le jeton précédent ne vaut plus rien.
JETON="$(connexion "$EMAIL_ADMIN" "$NOUVEAU_ADMIN")"
journal "création du lecteur $EMAIL_LECTEUR" journal "création du lecteur $EMAIL_LECTEUR"
REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \ REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")" -d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")"
MDP_TEMPORAIRE="$(jq -r '.temporary_password' <<<"$REPONSE")" MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")"
[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; }
JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")" JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")"
NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)" NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)"
changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR" JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")"
JETON="$(connexion "$EMAIL_LECTEUR" "$NOUVEAU_LECTEUR")"
# Vérifie que le jeton ouvre bien une route gardée avant de le rendre. # Vérifie que le jeton ouvre bien une route gardée avant de le rendre.
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")" CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")"