Compare commits

...
10 changed files with 565 additions and 33 deletions
+325
View File
@@ -0,0 +1,325 @@
name: DAST
# 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, sème un site et quelques relevés (sans ça le scan ne
# 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 sur les alertes (`continue-on-error` sur la seule étape du scan) :
# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le
# 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
# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et
# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui.
on:
workflow_dispatch:
schedule:
# Un scan actif est long : hebdomadaire plutôt qu'à chaque PR.
- cron: "0 3 * * 1"
pull_request:
# Ne se lance sur une PR que si le scan lui-même change.
paths:
- ".github/workflows/dast.yml"
- "scripts/dast-token.sh"
permissions:
contents: read
concurrency:
group: dast-${{ github.ref }}
cancel-in-progress: true
jobs:
zap:
name: Scan OWASP ZAP de l'API
runs-on: ubuntu-latest
# 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
# l'extension TimescaleDB (cf. backend.yml).
services:
db:
image: timescale/timescaledb-ha:pg17
env:
POSTGRES_USER: enervision
POSTGRES_PASSWORD: change_me
POSTGRES_DB: enervision_dast
ports:
- "5433:5432"
options: >-
--health-cmd "pg_isready -U enervision -d enervision_dast"
--health-interval 10s
--health-timeout 5s
--health-retries 12
--health-start-period 40s
env:
# Base jetable : ZAP y écrira et le script y crée deux comptes.
DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast
APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur
APP_ENV: local
# 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 ; `scanner.maxScanDurationInMins`
# (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent.
APP_ACCESS_TOKEN_TTL_SECONDS: "3600"
PGPASSWORD: change_me
steps:
- name: Récupère le dépôt
uses: actions/checkout@v7
- name: Installe uv
# Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce,
# contrairement à actions/checkout ou actions/upload-artifact, premières parties).
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
with:
enable-cache: true
cache-dependency-glob: apps/backend/uv.lock
# `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job
# « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un
# run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est
# 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
- name: Installe l'interpréteur déclaré par .python-version
run: uv python install
working-directory: apps/backend
# `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de
# build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis
# `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen
# --no-sync` pour ne rien résoudre ni reconstruire (règle S8544).
- name: Synchronise les dépendances sans dévier du verrou
run: uv sync --frozen --no-dev --no-install-project --no-build
working-directory: apps/backend
- name: Active TimescaleDB sur la base du scan
run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb"
- name: Applique les migrations
run: uv run --frozen --no-sync --no-build alembic upgrade head
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
run: |
nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \
--host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 &
for _ in $(seq 1 30); do
curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0
sleep 2
done
echo "L'API ne répond pas sur /health/ready" >&2
cat "$RUNNER_TEMP/api.log" >&2
exit 1
working-directory: apps/backend
- name: Crée le compte lecteur du scan
id: jeton
run: |
jeton="$(../../scripts/dast-token.sh)"
echo "::add-mask::$jeton"
echo "jeton=$jeton" >> "$GITHUB_OUTPUT"
working-directory: apps/backend
# Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici
# (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible,
# 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 vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte
# n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les
# requêtes un en-tête au nom vide (`: Bearer <jeton>`), qu'uvicorn refuse par un 400
# (« 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
# 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.
#
# `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne
# 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
id: zap
continue-on-error: true
env:
JETON: ${{ steps.jeton.outputs.jeton }}
run: |
set -o pipefail
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"
# Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un
# montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit.
# ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et
# `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui
# ressemblent à un scan actif, pour un daemon mort depuis le début.
#
# Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`,
# l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue
# (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub
# Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi »
# en une fraction de seconde, sans le moindre journal ni rapport produit.
sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf"
sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf"
docker run --name zap --network host \
-v "$PWD/zap-out:/zap/wrk:rw" \
-v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \
ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \
-t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \
-T 10 \
-r zap-report.html -J zap-report.json -w zap-report.md \
-z "-configfile /zap/auth.conf \
-config globalexcludeurl.url_list.url(0).description=auth-etat \
-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 scanner.maxScanDurationInMins=15 \
-config scanner.maxRuleDurationInMins=5" \
2>&1 | tee "$RUNNER_TEMP/zap-stdout.log"
- name: Récupère les journaux de ZAP
if: always()
run: |
mkdir -p zap-logs
# ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton,
# à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées,
# 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
# `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien
# testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé
# que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe
# : 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é
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)"
echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)"
if [ "${importees:-0}" -lt "$minimum" ]; then
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
fi
# Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs
# (constaté : base sans données, toutes les routes de site répondaient 404).
#
# 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: |
python3 - <<'PY'
import json
import sys
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é
if: always()
run: |
if [ -f zap-out/zap-report.md ]; then
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
echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Publie les rapports
if: always()
uses: actions/upload-artifact@v7
with:
name: zap-report
path: |
zap-out/
zap-logs/
if-no-files-found: warn
# Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact
# (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
if: failure() || steps.zap.outcome == 'failure'
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"
_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)]
+3 -1
View File
@@ -94,7 +94,9 @@ TAGS: Final[list[dict[str, Any]]] = [
cookie_de_rafraichissement = APIKeyCookie(
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=(
"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 "
+22 -22
View File
@@ -213,7 +213,7 @@
},
"security": [
{
"Cookie de rafraîchissement": []
"CookieRafraichissement": []
}
]
}
@@ -252,7 +252,7 @@
},
"security": [
{
"Cookie de rafraîchissement": []
"CookieRafraichissement": []
}
]
}
@@ -301,7 +301,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -347,7 +347,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -423,7 +423,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -673,7 +673,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
},
@@ -757,7 +757,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -771,7 +771,7 @@
"operationId": "update_user_api_v1_users__user_id__patch",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -889,7 +889,7 @@
"operationId": "reset_password_api_v1_users__user_id__password_reset_post",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1023,7 +1023,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -1037,7 +1037,7 @@
"operationId": "get_site_api_v1_sites__site_id__get",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1124,7 +1124,7 @@
"operationId": "get_current_api_v1_sites__site_id__current_get",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1211,7 +1211,7 @@
"operationId": "list_alerts_api_v1_alerts_get",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1361,7 +1361,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -1375,7 +1375,7 @@
"operationId": "get_recommendation_api_v1_recommendations__recommendation_id__get",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1462,7 +1462,7 @@
"operationId": "generate_recommendations_api_v1_recommendations_generate_post",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1588,7 +1588,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -1602,7 +1602,7 @@
"operationId": "list_readings_api_v1_readings_get",
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
],
"parameters": [
@@ -1799,7 +1799,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -1855,7 +1855,7 @@
},
"security": [
{
"Jeton d'accès": []
"JetonAcces": []
}
]
}
@@ -3225,13 +3225,13 @@
}
},
"securitySchemes": {
"Cookie de rafraîchissement": {
"CookieRafraichissement": {
"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`.",
"in": "cookie",
"name": "ev_refresh"
},
"Jeton d'accès": {
"JetonAcces": {
"type": "http",
"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:
schemes = schema["components"]["securitySchemes"]
assert schemes["Cookie de rafraîchissement"]["in"] == "cookie"
assert schemes["Cookie de rafraîchissement"]["name"] == "ev_refresh"
assert schemes["CookieRafraichissement"]["in"] == "cookie"
assert schemes["CookieRafraichissement"]["name"] == "ev_refresh"
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 |
| `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
`Cookie de rafraîchissement` pour `/auth/refresh` et `/auth/logout`. **Le second est purement
Deux schémas de sécurité sont déclarés : `JetonAcces` pour le porteur JWT, et
`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
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.
+119 -5
View File
@@ -68,23 +68,36 @@ flowchart TB
push --> mv & ms
push --> av & ab
push --> it
push --> sb1 & sb2 --> sscan
push --> sb1 & sb2 & sb3 --> sscan
subgraph cd["Déploiement · deploy.yml"]
dep["deploy<br/>runner eni-g3, environnement rec ou prod"]
end
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
Les six workflows hébergés par GitHub se déclenchent sur `push` **et** sur `pull_request`,
filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur
`pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur
`apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur
`etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre
fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline.
`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun
déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et
`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à
chaque commit.
Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`,
`ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et
`apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux
@@ -108,7 +121,8 @@ environnement.
## Déploiement
`deploy.yml` est le septième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur
`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`,
`sonarqube`, `dast`, plus lui-même), 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
joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port
entrant n'est ouvert.
@@ -254,12 +268,112 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce
à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se
régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow.
## Scan DAST (OWASP ZAP)
Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit,
ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h
UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs
minutes.
Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base
jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas
encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque
`/{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.
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
`POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin
jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus.
- **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
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
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.
**Deux pièges d'autorisation** sur ce fichier de configuration (`zap-auth.conf`), tous les deux
propres au montage bind Docker : le conteneur y lit avec son propre uid (1000), distinct de celui
du runner qui l'a écrit, sans remappage automatique.
- Un `chmod 600` seul rend le fichier illisible pour le conteneur (« File not readable :
/zap/auth.conf »). ZAP échoue dès le lancement, mais `zap-api-scan.py` attend les `-T` minutes
complètes avant d'abandonner : dix minutes qui ressemblent à un scan actif, pour un daemon mort
depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`.
- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui
suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes
bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une
fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent
passer par `sudo`.
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
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.** Deux garde-fous, eux, **bloquent** :
- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26
opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP
refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en
ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette
étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus
permissive qu'elle ne l'était.
- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration
du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que
pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans
erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête,
y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner
le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre
d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec
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.
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/`, 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`, sur la seule étape du scan) pour ce qui est
des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport
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`,
pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy
(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un
second passage sur la stack complète reste à faire.
## Ce qui manque, et pourquoi
| Manque | Issue | Conséquence assumée |
|---|---|---|
| Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit |
| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances |
| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées |
| Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI |
| Tests de charge | #47 | Aucun garde-fou de performance |
| Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés |
+1
View File
@@ -38,6 +38,7 @@ lecture seule ; plusieurs lignes resteront à compléter une fois les endpoints
| Caviardage des jetons, empreintes, mots de passe et cookies dans les journaux | `app/core/logging.py` | A09, A02 |
| Cinq gardes de configuration qui refusent le démarrage plutôt que de dégrader silencieusement | `app/core/config.py` | A05 |
| Documentation interactive fermée hors développement, `/metrics` derrière un jeton, sonde qui ne publie plus de version | `app/main.py`, `app/api/security.py` | A05 |
| Scan dynamique OWASP ZAP de l'API authentifiée (compte `lecteur` jetable), non bloquant, configuration par défaut du backend uniquement (ni TLS ni en-têtes du reverse proxy) | `.github/workflows/dast.yml`, `scripts/dast-token.sh` | A05, API8 Security Misconfiguration |
| En-têtes `nosniff`, `DENY`, `no-referrer`, et `no-store` sur les routes d'authentification | `app/api/middleware.py` | A05 |
| Refus de rétrograder ou désactiver le dernier administrateur actif | `app/services/user.py` | A04 Insecure Design |
| Amorçage du premier administrateur hors dépôt, mot de passe jamais dans `argv` ni dans Git | `app/cli.py` | A02, A05 |
+8
View File
@@ -1,3 +1,11 @@
# Scripts
Outillage local du monorepo. Les taches courantes passent par le `Makefile` racine.
## dast-token.sh
Prépare le scan DAST (`.github/workflows/dast.yml`) : sur une API déjà démarrée, crée un compte
`lecteur` jetable, lui fait passer le changement de mot de passe obligatoire et écrit son jeton
d'accès sur la sortie standard. À lancer depuis `apps/backend`, contre une base **jetable** (il y
crée deux comptes) : `BASE_URL=http://localhost:8000 ../../scripts/dast-token.sh`. Nécessite `curl`,
`jq` et `openssl`.
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le
# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard.
#
# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que
# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes
# gardées et le scan ne testerait rien de l'API authentifiée.
#
# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin
# frapperait POST /users ou la réinitialisation de mots de passe pour de bon.
#
# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique).
# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent
# une base JETABLE : le script y crée deux comptes.
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8000}"
API="$BASE_URL/api/v1"
SUFFIXE="$(openssl rand -hex 4)"
EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr"
EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr"
# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial.
nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; }
# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton.
journal() { echo "dast-token: $*" >&2; }
connexion() {
local email="$1" mot_de_passe="$2"
curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \
-d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \
| 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() {
local jeton="$1" ancien="$2" nouveau="$3"
curl -fsS -X POST "$API/auth/password" \
-H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \
-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"
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")"
[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; }
JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")"
NOUVEAU_ADMIN="$(nouveau_mot_de_passe)"
JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")"
journal "création du lecteur $EMAIL_LECTEUR"
REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")"
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")"
NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)"
JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")"
# 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" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; }
journal "jeton du lecteur prêt"
echo "$JETON"