From e220f8f0c65bf40e425a0d5f5c7b51bdbaacb1ec Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 22 Sep 2026 14:08:51 +0200 Subject: [PATCH] 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")"