From 67dcf506e98326e0bc72dbcd12c383b43b027b89 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 16:11:36 +0200 Subject: [PATCH 1/9] ci(backend): ajoute un scan DAST OWASP ZAP de l'API avec un compte lecteur jetable* --- .github/workflows/dast.yml | 160 ++++++++++++++++++++++++ docs/architecture/50-cicd.md | 40 +++++- docs/architecture/owasp-traceabilite.md | 1 + scripts/README.md | 8 ++ scripts/dast-token.sh | 69 ++++++++++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/dast.yml create mode 100755 scripts/dast-token.sh diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml new file mode 100644 index 0000000..5c5a13e --- /dev/null +++ b/.github/workflows/dast.yml @@ -0,0 +1,160 @@ +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, 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 +# est publié en artefact et dans le résumé du job. Le fixer en seuil viendra une fois les alertes +# triées. +# +# 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 + timeout-minutes: 60 + + # 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. + APP_ACCESS_TOKEN_TTL_SECONDS: "3600" + PGPASSWORD: change_me + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v4 + + - name: Installe uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + working-directory: apps/backend + + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --frozen --no-dev + 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 alembic upgrade head + working-directory: apps/backend + + - name: Démarre l'API + run: | + nohup uv run 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 + + # `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie doit + # être inscriptible par l'utilisateur du conteneur (uid 1000). + # + # 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. + - name: Scan ZAP + id: zap + continue-on-error: true + run: | + mkdir -p zap-out && chmod 777 zap-out + docker run --rm --network host -v "$PWD/zap-out:/zap/wrk:rw" \ + ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \ + -t http://localhost:8000/openapi.json -f openapi \ + -T 30 \ + -r zap-report.html -J zap-report.json -w zap-report.md \ + -z "-config replacer.full_list(0).description=auth \ + -config replacer.full_list(0).enabled=true \ + -config replacer.full_list(0).matchtype=REQ_HEADER \ + -config replacer.full_list(0).matchstring=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).enabled=true \ + -config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*'" + + - name: Publie le résumé + if: always() + run: | + if [ -f zap-out/zap-report.md ]; then + cat zap-out/zap-report.md >> "$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@v4 + with: + name: zap-report + path: zap-out/ + if-no-files-found: warn + + # Un scan sans compte authentifié ne testerait que les routes publiques : mieux vaut le + # dire que le laisser passer pour vert. + - name: Journal de l'API en cas d'échec + if: failure() || steps.zap.outcome == 'failure' + run: cat "$RUNNER_TEMP/api.log" || true diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 4fcd851..f8a290d 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -59,7 +59,7 @@ flowchart TB ## Déclenchement -Les cinq workflows se déclenchent sur `push` **et** sur `pull_request`, filtrés par **chemin** : +Les workflows de qualité 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/**`, `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 @@ -187,12 +187,48 @@ run. Aucune clé de déploiement n'existe encore, puisqu'il n'y a pas de déploi déploiement est porté par l'issue #21, les secrets qu'il consommera et leur injection par l'issue #22. +## 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) et le backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son +jeton. ZAP charge le contrat `/openapi.json` (`zap-api-scan.py -f openapi`) et envoie ce jeton +dans l'en-tête `Authorization`. Sans lui, ZAP ne verrait que les deux sondes et `/auth/login`. + +Trois 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`. +- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut + dure 15 minutes et le scan bien plus. + +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. + +**Non bloquant pour l'instant** (`continue-on-error`). Le volume d'alertes d'un premier passage est +inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et 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 | |---|---|---| | Job de déploiement (CD) | #21 | La chaîne s'arrête au merge. Rien ne part vers une machine | -| 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 | diff --git a/docs/architecture/owasp-traceabilite.md b/docs/architecture/owasp-traceabilite.md index 96df248..7202cfa 100644 --- a/docs/architecture/owasp-traceabilite.md +++ b/docs/architecture/owasp-traceabilite.md @@ -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 | diff --git a/scripts/README.md b/scripts/README.md index 9395406..848aeb4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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`. diff --git a/scripts/dast-token.sh b/scripts/dast-token.sh new file mode 100755 index 0000000..fdbe4a3 --- /dev/null +++ b/scripts/dast-token.sh @@ -0,0 +1,69 @@ +#!/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() { + curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$1" --arg p "$2" '{email:$e, password:$p}')" | jq -r '.access_token' +} + +changer_mot_de_passe() { + local jeton="$1" ancien="$2" nouveau="$3" + curl -fsS -o /dev/null -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}')" +} + +journal "création de l'administrateur $EMAIL_ADMIN" +SORTIE="$(uv run python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)" +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; } + +JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")" +NOUVEAU_ADMIN="$(nouveau_mot_de_passe)" +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" +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' <<<"$REPONSE")" + +JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")" +NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)" +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. +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" From de88c4f156228c89ce4fb9b28c4378bab0d70ec1 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 16:26:29 +0200 Subject: [PATCH 2/9] fix(ci): corrige les issues Sonar du scan DAST et fait echouer un scan qui n'importe pas le contrat --- .github/workflows/dast.yml | 53 +++++++++++++++++++++++++++++------- docs/architecture/50-cicd.md | 9 +++++- scripts/dast-token.sh | 6 ++-- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 5c5a13e..f85ac44 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -69,7 +69,8 @@ jobs: uses: actions/checkout@v4 - name: Installe uv - uses: astral-sh/setup-uv@v5 + # Épinglé sur le commit du tag v5 (règle Sonar githubactions:S7637). + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock @@ -78,20 +79,24 @@ jobs: 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 + 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 alembic upgrade head + run: uv run --frozen --no-sync --no-build alembic upgrade head working-directory: apps/backend - name: Démarre l'API run: | - nohup uv run uvicorn app.main:create_app --factory --host 0.0.0.0 --port 8000 \ + 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 @@ -110,21 +115,27 @@ jobs: echo "jeton=$jeton" >> "$GITHUB_OUTPUT" working-directory: apps/backend - # `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie doit - # être inscriptible par l'utilisateur du conteneur (uid 1000). + # `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie + # appartient à l'utilisateur du conteneur (uid 1000), sans droits d'écriture pour les autres + # (règle Sonar S2612 : pas de `chmod 777`). # # 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. + # + # Le conteneur n'est pas jetable (`--rm`) : son journal interne (`zap.log`) est copié en + # sortie, c'est lui qui dit pourquoi un import OpenAPI a échoué. - name: Scan ZAP id: zap continue-on-error: true run: | - mkdir -p zap-out && chmod 777 zap-out - docker run --rm --network host -v "$PWD/zap-out:/zap/wrk:rw" \ + set -o pipefail + mkdir -p zap-out + sudo chown 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 \ -t http://localhost:8000/openapi.json -f openapi \ - -T 30 \ + -T 30 -d \ -r zap-report.html -J zap-report.json -w zap-report.md \ -z "-config replacer.full_list(0).description=auth \ -config replacer.full_list(0).enabled=true \ @@ -134,7 +145,29 @@ jobs: -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).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).*'" \ + 2>&1 | tee "$RUNNER_TEMP/zap-stdout.log" + + - name: Récupère les journaux de ZAP + if: always() + run: | + cp "$RUNNER_TEMP/zap-stdout.log" zap-out/zap-stdout.log 2>/dev/null || true + docker cp zap:/home/zap/.ZAP/zap.log zap-out/zap.log 2>/dev/null || 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 + # testé : ZAP « réussit » aussi quand l'import du contrat n'a chargé que quelques URL, et + # ne teste alors que des 404 (constaté au premier passage : 2 URL importées sur 26 + # opérations). Les alertes restent non bloquantes, ce garde-fou-là bloque. + - name: Vérifie que le contrat a bien été importé + run: | + importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' zap-out/zap-stdout.log | tail -1)" + minimum=10 + echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune}" + 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-out/zap.log dans l'artefact zap-report." + exit 1 + fi - name: Publie le résumé if: always() diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index f8a290d..963267c 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -214,7 +214,14 @@ Les routes d'authentification qui changent l'état du compte (`login`, `password `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. -**Non bloquant pour l'instant** (`continue-on-error`). Le volume d'alertes d'un premier passage est +**Un scan vert n'est pas un scan qui a testé quelque chose.** Au premier passage, le job était vert +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`) est publié dans l'artefact `zap-report` pour diagnostiquer un 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 inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et dans le résumé du job. Fixer un seuil viendra une fois les alertes triées. diff --git a/scripts/dast-token.sh b/scripts/dast-token.sh index fdbe4a3..db47c86 100755 --- a/scripts/dast-token.sh +++ b/scripts/dast-token.sh @@ -28,8 +28,10 @@ nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; } 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 "$1" --arg p "$2" '{email:$e, password:$p}')" | jq -r '.access_token' + -d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \ + | jq -r '.access_token' } changer_mot_de_passe() { @@ -40,7 +42,7 @@ changer_mot_de_passe() { } journal "création de l'administrateur $EMAIL_ADMIN" -SORTIE="$(uv run python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)" +SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)" 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; } From 00fab49d80599a1870c5f026a3bcf277ff4ecefd Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 16:42:25 +0200 Subject: [PATCH 3/9] fix(ci): charge le contrat OpenAPI depuis un fichier dans le scan DAST et publie les journaux ZAP --- .github/workflows/dast.yml | 34 ++++++++++++++++++++++++++-------- docs/architecture/50-cicd.md | 12 +++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index f85ac44..bfef702 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -130,11 +130,26 @@ jobs: continue-on-error: true run: | set -o pipefail - mkdir -p zap-out - sudo chown 1000:1000 zap-out + mkdir -p zap-out zap-logs + # Le contrat est chargé depuis un fichier : `importUrl` a répondu 400 au premier passage + # et ZAP est alors reparti explorer la racine (2 URL, que des 404). + curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json + # Les noms des schémas de sécurité du contrat sont accentués (`Jeton d'accès`...) : ZAP + # 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 \ - -t http://localhost:8000/openapi.json -f openapi \ + -t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \ -T 30 -d \ -r zap-report.html -J zap-report.json -w zap-report.md \ -z "-config replacer.full_list(0).description=auth \ @@ -151,8 +166,9 @@ jobs: - name: Récupère les journaux de ZAP if: always() run: | - cp "$RUNNER_TEMP/zap-stdout.log" zap-out/zap-stdout.log 2>/dev/null || true - docker cp zap:/home/zap/.ZAP/zap.log zap-out/zap.log 2>/dev/null || true + mkdir -p zap-logs + cp "$RUNNER_TEMP/zap-stdout.log" zap-logs/zap-stdout.log || true + docker cp zap:/home/zap/.ZAP/zap.log zap-logs/zap.log || 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 @@ -161,11 +177,11 @@ jobs: # opérations). Les alertes restent non bloquantes, ce garde-fou-là bloque. - name: Vérifie que le contrat a bien été importé run: | - importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' zap-out/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}" 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-out/zap.log dans l'artefact zap-report." + 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." exit 1 fi @@ -183,7 +199,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: zap-report - path: zap-out/ + path: | + zap-out/ + zap-logs/ if-no-files-found: warn # Un scan sans compte authentifié ne testerait que les routes publiques : mieux vaut le diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 963267c..7b55240 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -219,7 +219,17 @@ alors que ZAP n'avait importé que **2 URL sur 26 opérations** du contrat (`Num 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`) est publié dans l'artefact `zap-report` pour diagnostiquer un import raté. +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 +**400**, le contrat n'est pas chargé et ZAP se rabat sur l'exploration de la racine. Le job charge +donc le contrat **depuis un fichier** (`-t /zap/wrk/openapi.json -O http://localhost:8000`) et +renomme dans cette copie, sans toucher au contrat versionné, les deux schémas de sécurité aux noms +accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La +cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne. +Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit +plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède. **Non bloquant pour l'instant** (`continue-on-error`) pour ce qui est des alertes. Le volume d'alertes d'un premier passage est inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et dans le résumé du From 1bec2c1376d4b22fcf666321e0e43c3b0e492fe1 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 16:49:44 +0200 Subject: [PATCH 4/9] fix(ci): diagnostique les 400 du scan DAST avec socat et echoue si toutes les reponses sont des 4xx --- .github/workflows/dast.yml | 36 ++++++++++++++++++++++++++++++++++-- docs/architecture/50-cicd.md | 8 ++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index bfef702..b7c4169 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -94,10 +94,22 @@ jobs: run: uv run --frozen --no-sync --no-build alembic upgrade head working-directory: apps/backend + # Diagnostic (à retirer une fois le scan authentifié qui fonctionne) : au deuxième passage + # ZAP importait bien le contrat (81 endpoints) mais TOUTES ses requêtes recevaient un 400 + # « Invalid HTTP request received » d'uvicorn, y compris `/health/live` sans authentification. + # uvicorn (httptools) ne dit pas ce qui l'a fait refuser. `socat -v` s'intercale sur le port + # 8000 et journalise les octets échangés, sans rien modifier. + - name: Installe socat + run: sudo apt-get update -qq && sudo apt-get install -y -qq socat + + # L'API écoute sur 8001, `socat` relaie 8000 vers elle. `--http h11` : autre analyseur que + # httptools (llhttp, strict), au cas où c'est lui qui refuse les requêtes de ZAP. - 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 & + nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \ + --http h11 --host 127.0.0.1 --port 8001 > "$RUNNER_TEMP/api.log" 2>&1 & + nohup socat -v -d TCP-LISTEN:8000,fork,reuseaddr TCP:127.0.0.1:8001 \ + > /dev/null 2> "$RUNNER_TEMP/socat.log" & for _ in $(seq 1 30); do curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0 sleep 2 @@ -169,6 +181,12 @@ jobs: mkdir -p zap-logs cp "$RUNNER_TEMP/zap-stdout.log" zap-logs/zap-stdout.log || true docker cp zap:/home/zap/.ZAP/zap.log zap-logs/zap.log || true + cp "$RUNNER_TEMP/api.log" zap-logs/api.log || true + # Octets échangés entre ZAP et l'API : début du dump, et contexte du premier 400. Le + # jeton (valable une heure, base jetable) est masqué avant publication. + masque() { sed -E 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; } + head -c 200000 "$RUNNER_TEMP/socat.log" | masque > zap-logs/socat-debut.log || true + grep -n -m3 -B14 -A3 "400 Bad Request" "$RUNNER_TEMP/socat.log" | masque > zap-logs/socat-premier-400.log || 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 @@ -185,6 +203,20 @@ jobs: exit 1 fi + # Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des 4xx (constaté au + # deuxième passage : 81 endpoints, 100 % de 400, job vert). Un scan dont toutes les réponses + # 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 + run: | + if [ ! -f zap-out/zap-report.md ]; then + echo "::error::Aucun rapport ZAP produit : le scan n'a rien testé." + exit 1 + 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/socat-premier-400.log dans l'artefact zap-report." + exit 1 + fi + - name: Publie le résumé if: always() run: | diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index 7b55240..c9ac95c 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -228,6 +228,14 @@ donc le contrat **depuis un fichier** (`-t /zap/wrk/openapi.json -O http://local renomme dans cette copie, sans toucher au contrat versionné, les deux schémas de sécurité aux noms accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne. +Deuxième diagnostic (contrat importé, 81 endpoints) : **toutes** les requêtes de ZAP recevaient un 400 +`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, +et le job restait vert. Un second garde-fou fait donc échouer le job si 100 % des réponses sont des +4xx. Tant que la cause n'est pas établie, le job intercale `socat -v` entre ZAP et l'API (octets +échangés publiés dans `zap-logs/`, jeton masqué) et lance uvicorn avec `--http h11` : uvicorn n'indique +pas ce que son analyseur a refusé. Ce diagnostic est à retirer une fois le scan authentifié qui +fonctionne. + Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède. From 6d741e45fbfd7fb729d796323a3c8b306cbc4013 Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 21 Sep 2026 16:59:13 +0200 Subject: [PATCH 5/9] fix(ci): corrige la cle matchstr de l'en-tete Authorization dans le scan DAST et retire le diagnostic socat --- .github/workflows/dast.yml | 28 ++++++++-------------------- docs/architecture/50-cicd.md | 13 +++++++------ 2 files changed, 15 insertions(+), 26 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index b7c4169..bcd6317 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -94,22 +94,10 @@ jobs: run: uv run --frozen --no-sync --no-build alembic upgrade head working-directory: apps/backend - # Diagnostic (à retirer une fois le scan authentifié qui fonctionne) : au deuxième passage - # ZAP importait bien le contrat (81 endpoints) mais TOUTES ses requêtes recevaient un 400 - # « Invalid HTTP request received » d'uvicorn, y compris `/health/live` sans authentification. - # uvicorn (httptools) ne dit pas ce qui l'a fait refuser. `socat -v` s'intercale sur le port - # 8000 et journalise les octets échangés, sans rien modifier. - - name: Installe socat - run: sudo apt-get update -qq && sudo apt-get install -y -qq socat - - # L'API écoute sur 8001, `socat` relaie 8000 vers elle. `--http h11` : autre analyseur que - # httptools (llhttp, strict), au cas où c'est lui qui refuse les requêtes de ZAP. - name: Démarre l'API run: | nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \ - --http h11 --host 127.0.0.1 --port 8001 > "$RUNNER_TEMP/api.log" 2>&1 & - nohup socat -v -d TCP-LISTEN:8000,fork,reuseaddr TCP:127.0.0.1:8001 \ - > /dev/null 2> "$RUNNER_TEMP/socat.log" & + --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 @@ -131,6 +119,11 @@ jobs: # appartient à l'utilisateur du conteneur (uid 1000), sans droits d'écriture pour les autres # (règle Sonar S2612 : pas de `chmod 777`). # + # Piège : 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 `), que uvicorn refuse par un 400 (« Invalid HTTP request + # received »), y compris sur les routes publiques. + # # 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. @@ -167,7 +160,7 @@ jobs: -z "-config replacer.full_list(0).description=auth \ -config replacer.full_list(0).enabled=true \ -config replacer.full_list(0).matchtype=REQ_HEADER \ - -config replacer.full_list(0).matchstring=Authorization \ + -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 \ @@ -182,11 +175,6 @@ jobs: cp "$RUNNER_TEMP/zap-stdout.log" zap-logs/zap-stdout.log || true docker cp zap:/home/zap/.ZAP/zap.log zap-logs/zap.log || true cp "$RUNNER_TEMP/api.log" zap-logs/api.log || true - # Octets échangés entre ZAP et l'API : début du dump, et contexte du premier 400. Le - # jeton (valable une heure, base jetable) est masqué avant publication. - masque() { sed -E 's/(Authorization: Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; } - head -c 200000 "$RUNNER_TEMP/socat.log" | masque > zap-logs/socat-debut.log || true - grep -n -m3 -B14 -A3 "400 Bad Request" "$RUNNER_TEMP/socat.log" | masque > zap-logs/socat-premier-400.log || 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 @@ -213,7 +201,7 @@ jobs: exit 1 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/socat-premier-400.log dans l'artefact zap-report." + 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 diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index c9ac95c..8330047 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -229,12 +229,13 @@ renomme dans cette copie, sans toucher au contrat versionné, les deux schémas accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne. Deuxième diagnostic (contrat importé, 81 endpoints) : **toutes** les requêtes de ZAP recevaient un 400 -`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, -et le job restait vert. Un second garde-fou fait donc échouer le job si 100 % des réponses sont des -4xx. Tant que la cause n'est pas établie, le job intercale `socat -v` entre ZAP et l'API (octets -échangés publiés dans `zap-logs/`, jeton masqué) et lance uvicorn avec `--http h11` : uvicorn n'indique -pas ce que son analyseur a refusé. Ce diagnostic est à retirer une fois le scan authentifié qui -fonctionne. +`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, et +le job restait vert. Un dump des octets échangés (`socat -v`, retiré depuis) a montré la cause : ZAP +ajoutait à chaque requête une ligne d'en-tête au **nom vide**, `: Bearer `. La clé de +configuration du nom d'en-tête pour la règle Replacer est **`matchstr`** ; le job écrivait +`matchstring` (nom utilisé par le job d'automatisation ZAP, pas par `-config`). ZAP accepte +n'importe quelle clé `-config` sans erreur, il a donc laissé le nom vide. Le job porte deux garde-fous +qui font échouer un scan qui n'a rien testé : moins de 10 URL importées, ou 100 % de réponses 4xx. Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède. From d9103ee4eddf90ce380f63aeba0e70ac40826ef0 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 22 Sep 2026 11:29:54 +0200 Subject: [PATCH 6/9] fix(ci): prune-cache set false --- .github/workflows/dast.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index bcd6317..6924ea3 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -74,6 +74,11 @@ jobs: with: enable-cache: true cache-dependency-glob: apps/backend/uv.lock + # `prune-cache` vaut `true` par défaut sur ce commit : l'étape de post-job « Pruning + # cache » est restée bloquée 5 minutes avant d'échouer (exit code 2), sans lien avec le + # scan lui-même (les 16 étapes précédentes passaient). Le prune n'est qu'une optimisation + # de taille de cache, pas une garantie : le désactiver ici retire le blocage. + prune-cache: false - name: Installe l'interpréteur déclaré par .python-version run: uv python install From e220f8f0c65bf40e425a0d5f5c7b51bdbaacb1ec Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 22 Sep 2026 14:08:51 +0200 Subject: [PATCH 7/9] fix(ci,backend): securise le jeton du scan DAST, seme des donnees et refait ses garde-fous --- .github/workflows/dast.yml | 225 +++++++++++++++++-------- apps/backend/app/api/deps.py | 4 +- apps/backend/app/api/openapi.py | 4 +- apps/backend/openapi.json | 44 ++--- apps/backend/tests/api/test_openapi.py | 4 +- docs/architecture/20-backend.md | 6 +- docs/architecture/50-cicd.md | 98 +++++++---- scripts/dast-token.sh | 27 +-- 8 files changed, 267 insertions(+), 145 deletions(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 6924ea3..e781f4e 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -1,12 +1,14 @@ 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, crée un compte `lecteur` jetable (scripts/dast-token.sh), -# puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte. +# 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 : le volume d'alertes d'un premier passage est inconnu. Le rapport -# est publié en artefact et dans le résumé du job. Le fixer en seuil viendra une fois les alertes -# triées. +# 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 @@ -34,7 +36,10 @@ jobs: zap: name: Scan OWASP ZAP de l'API 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 # l'extension TimescaleDB (cf. backend.yml). @@ -60,24 +65,27 @@ jobs: 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. + # 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@v4 + uses: actions/checkout@v7 - name: Installe uv - # Épinglé sur le commit du tag v5 (règle Sonar githubactions:S7637). - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5 + # É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 sur ce commit : l'étape de post-job « Pruning - # cache » est restée bloquée 5 minutes avant d'échouer (exit code 2), sans lien avec le - # scan lui-même (les 16 étapes précédentes passaient). Le prune n'est qu'une optimisation - # de taille de cache, pas une garantie : le désactiver ici retire le blocage. + # `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 @@ -99,6 +107,24 @@ jobs: 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 \ @@ -120,108 +146,157 @@ jobs: echo "jeton=$jeton" >> "$GITHUB_OUTPUT" working-directory: apps/backend - # `--network host` : ZAP atteint l'API sur le localhost du runner. Le dossier de sortie - # appartient à l'utilisateur du conteneur (uid 1000), sans droits d'écriture pour les autres - # (règle Sonar S2612 : pas de `chmod 777`). + # É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 : 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 `), que uvicorn refuse par un 400 (« Invalid HTTP request - # received »), y compris sur les routes publiques. + # 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 `), 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. # - # Le conteneur n'est pas jetable (`--rm`) : son journal interne (`zap.log`) est copié en - # sortie, c'est lui qui dit pourquoi un import OpenAPI a échoué. + # `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 - mkdir -p zap-out zap-logs - # Le contrat est chargé depuis un fichier : `importUrl` a répondu 400 au premier passage - # et ZAP est alors reparti explorer la racine (2 URL, que des 404). - curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json - # Les noms des schémas de sécurité du contrat sont accentués (`Jeton d'accès`...) : ZAP - # 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" \ + 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" + chmod 600 "$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 30 -d \ + -T 10 \ -r zap-report.html -J zap-report.json -w zap-report.md \ - -z "-config replacer.full_list(0).description=auth \ - -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 }}' \ + -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 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 - cp "$RUNNER_TEMP/zap-stdout.log" zap-logs/zap-stdout.log || true - docker cp zap:/home/zap/.ZAP/zap.log zap-logs/zap.log || true - cp "$RUNNER_TEMP/api.log" zap-logs/api.log || true + # 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é : ZAP « réussit » aussi quand l'import du contrat n'a chargé que quelques URL, et - # ne teste alors que des 404 (constaté au premier passage : 2 URL importées sur 26 - # opérations). Les alertes restent non bloquantes, ce garde-fou-là bloque. + # 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)" - minimum=10 - echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune}" + 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 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 fi - # Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des 4xx (constaté au - # deuxième passage : 81 endpoints, 100 % de 400, job vert). Un scan dont toutes les réponses - # 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 + # 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: | - if [ ! -f zap-out/zap-report.md ]; then - echo "::error::Aucun rapport ZAP produit : le scan n'a rien testé." - exit 1 - 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 + 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 - 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 echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY" fi - name: Publie les rapports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: zap-report path: | @@ -229,8 +304,10 @@ jobs: zap-logs/ if-no-files-found: warn - # Un scan sans compte authentifié ne testerait que les routes publiques : mieux vaut le - # dire que le laisser passer pour vert. + # 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 diff --git a/apps/backend/app/api/deps.py b/apps/backend/app/api/deps.py index 6662f81..9f4bf4f 100644 --- a/apps/backend/app/api/deps.py +++ b/apps/backend/app/api/deps.py @@ -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)] diff --git a/apps/backend/app/api/openapi.py b/apps/backend/app/api/openapi.py index 8ca8c08..aef4abf 100644 --- a/apps/backend/app/api/openapi.py +++ b/apps/backend/app/api/openapi.py @@ -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 " diff --git a/apps/backend/openapi.json b/apps/backend/openapi.json index 67b3877..913df38 100644 --- a/apps/backend/openapi.json +++ b/apps/backend/openapi.json @@ -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" } diff --git a/apps/backend/tests/api/test_openapi.py b/apps/backend/tests/api/test_openapi.py index 50e3c3a..3fba602 100644 --- a/apps/backend/tests/api/test_openapi.py +++ b/apps/backend/tests/api/test_openapi.py @@ -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: diff --git a/docs/architecture/20-backend.md b/docs/architecture/20-backend.md index c499dca..b6b18be 100644 --- a/docs/architecture/20-backend.md +++ b/docs/architecture/20-backend.md @@ -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. diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index f1555f2..85fff61 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -57,13 +57,21 @@ flowchart TB push --> fd push --> mv & ms push --> av & ab - push --> sb1 & sb2 --> sscan + push --> sb1 & sb2 & sb3 --> sscan subgraph cd["Déploiement · deploy.yml"] dep["deploy
runner eni-g3, environnement rec ou prod"] end push -->|"push sur dev ou main"| dep + + planifie["chaque lundi 3h UTC,
ou à la main"] + subgraph dastw["DAST · dast.yml"] + zscan["zap
seed + scan actif OWASP ZAP"] + end + + planifie --> zscan + push -->|"PR sur dast.yml
ou dast-token.sh"| zscan ``` ## Déclenchement @@ -97,7 +105,7 @@ environnement. ## 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 joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port 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. 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 -jeton. ZAP charge le contrat `/openapi.json` (`zap-api-scan.py -f openapi`) et envoie ce jeton -dans l'en-tête `Authorization`. Sans lui, ZAP ne verrait que les deux sondes et `/auth/login`. +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. -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 `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 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 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`, `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.** Au premier passage, le job était vert -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é. +**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** : -Diagnostic du premier passage : `zap-api-scan.py` appelle `importUrl` sur `/openapi.json`, ZAP répond -**400**, le contrat n'est pas chargé et ZAP se rabat sur l'exploration de la racine. Le job charge -donc le contrat **depuis un fichier** (`-t /zap/wrk/openapi.json -O http://localhost:8000`) et -renomme dans cette copie, sans toucher au contrat versionné, les deux schémas de sécurité aux noms -accentués (`Jeton d'accès`, `Cookie de rafraîchissement`) que l'analyseur de ZAP peut refuser. La -cause exacte du 400 n'est pas confirmée : si l'import échoue encore, `zap-logs/zap.log` la donne. -Deuxième diagnostic (contrat importé, 81 endpoints) : **toutes** les requêtes de ZAP recevaient un 400 -`Invalid HTTP request received` d'uvicorn, y compris `/api/v1/health/live` sans authentification, et -le job restait vert. Un dump des octets échangés (`socat -v`, retiré depuis) a montré la cause : ZAP -ajoutait à chaque requête une ligne d'en-tête au **nom vide**, `: Bearer `. La clé de -configuration du nom d'en-tête pour la règle Replacer est **`matchstr`** ; le job écrivait -`matchstring` (nom utilisé par le job d'automatisation ZAP, pas par `-config`). ZAP accepte -n'importe quelle clé `-config` sans erreur, il a donc laissé le nom vide. Le job porte deux garde-fous -qui font échouer un scan qui n'a rien testé : moins de 10 URL importées, ou 100 % de réponses 4xx. +- **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. -Piège de permissions : le dossier `zap-out` appartient à l'uid 1000 du conteneur, le runner n'y écrit -plus après le `chown` ; les journaux vont donc dans `zap-logs/`, que le runner possède. +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`) pour ce qui est des alertes. Le volume d'alertes d'un premier passage est -inconnu ; le rapport HTML/JSON/Markdown est publié en artefact `zap-report` et dans le résumé du -job. Fixer un seuil viendra une fois les alertes triées. +**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 diff --git a/scripts/dast-token.sh b/scripts/dast-token.sh index db47c86..2bbbdfb 100755 --- a/scripts/dast-token.sh +++ b/scripts/dast-token.sh @@ -34,34 +34,41 @@ connexion() { | 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 -o /dev/null -X POST "$API/auth/password" \ + 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}')" + -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" -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")" -[[ -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")" NOUVEAU_ADMIN="$(nouveau_mot_de_passe)" -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")" +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' <<<"$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")" NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)" -changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR" -JETON="$(connexion "$EMAIL_LECTEUR" "$NOUVEAU_LECTEUR")" +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")" From f58fc4ba8178c2225b2bf6e79be8110169965f3e Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 22 Sep 2026 14:31:29 +0200 Subject: [PATCH 8/9] fix(ci): corrige les permissions du fichier d'authentification qui bloquait le scan DAST 10 minutes --- .github/workflows/dast.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index e781f4e..018fb44 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -187,7 +187,13 @@ jobs: 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" - chmod 600 "$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. + sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf" + 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" \ From ea8f9d0a3a0813989f6ff79daf81e9fec2fb67f5 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 22 Sep 2026 14:57:29 +0200 Subject: [PATCH 9/9] fix(ci): applique aussi le chmod du fichier d'authentification DAST via sudo --- .github/workflows/dast.yml | 8 +++++++- docs/architecture/50-cicd.md | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 018fb44..095781e 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -192,8 +192,14 @@ jobs: # 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" - chmod 644 "$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" \ diff --git a/docs/architecture/50-cicd.md b/docs/architecture/50-cicd.md index be7b711..0e323b8 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -313,6 +313,20 @@ Décisions à savoir défendre : `-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.