fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous
This commit is contained in:
+151
-74
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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")"
|
||||||
|
|||||||
Reference in New Issue
Block a user