diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml new file mode 100644 index 0000000..095781e --- /dev/null +++ b/.github/workflows/dast.yml @@ -0,0 +1,325 @@ +name: DAST + +# Scan dynamique OWASP ZAP de l'API (issue #41). Il attaque une API qui tourne : le job démarre +# la base et le backend sur le runner, sème un site et quelques relevés (sans ça le scan ne +# frappe que des gestionnaires d'erreur), crée un compte `lecteur` jetable +# (scripts/dast-token.sh), puis lance ZAP sur le contrat OpenAPI avec le jeton de ce compte. +# +# Non bloquant pour l'instant sur les alertes (`continue-on-error` sur la seule étape du scan) : +# le volume d'un premier passage trié est inconnu. Deux étapes suivantes, elles, bloquent si le +# scan n'a rien testé (import du contrat, absence de toute réponse de succès) : un job vert doit +# vouloir dire qu'un scan a eu lieu. +# +# Piège : ce scan tape la configuration par défaut du backend (`APP_ENV=local`, pas de TLS, pas +# de reverse proxy). Il ne dit rien des en-têtes ni du TLS posés par le proxy en production, et +# remontera des alertes (HSTS absent...) qui n'existent pas derrière lui. + +on: + workflow_dispatch: + schedule: + # Un scan actif est long : hebdomadaire plutôt qu'à chaque PR. + - cron: "0 3 * * 1" + pull_request: + # Ne se lance sur une PR que si le scan lui-même change. + paths: + - ".github/workflows/dast.yml" + - "scripts/dast-token.sh" + +permissions: + contents: read + +concurrency: + group: dast-${{ github.ref }} + cancel-in-progress: true + +jobs: + zap: + name: Scan OWASP ZAP de l'API + runs-on: ubuntu-latest + # Généreux face aux ~2 minutes observées de bout en bout : le vrai plafond est + # `scanner.maxScanDurationInMins` (étape Scan ZAP), sous le TTL du jeton. Une annulation par + # ce timeout-ci n'exécute pas les étapes `always()` : mieux vaut ne jamais l'atteindre. + timeout-minutes: 30 + + # Même image que docker-compose.yml : la première migration refuse de s'appliquer sans + # l'extension TimescaleDB (cf. backend.yml). + services: + db: + image: timescale/timescaledb-ha:pg17 + env: + POSTGRES_USER: enervision + POSTGRES_PASSWORD: change_me + POSTGRES_DB: enervision_dast + ports: + - "5433:5432" + options: >- + --health-cmd "pg_isready -U enervision -d enervision_dast" + --health-interval 10s + --health-timeout 5s + --health-retries 12 + --health-start-period 40s + + env: + # Base jetable : ZAP y écrira et le script y crée deux comptes. + DATABASE_URL: postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_dast + APP_SECRET_KEY: secret-de-scan-assez-long-pour-le-validateur + APP_ENV: local + # Le jeton du lecteur doit survivre à toute la durée du scan (15 minutes par défaut). + # 3600 est le plafond accepté par la configuration ; `scanner.maxScanDurationInMins` + # (étape Scan ZAP) reste très en dessous, marge comprise pour les étapes qui l'entourent. + APP_ACCESS_TOKEN_TTL_SECONDS: "3600" + PGPASSWORD: change_me + + steps: + - name: Récupère le dépôt + uses: actions/checkout@v7 + + - name: Installe uv + # Épinglé sur le commit du tag v7 (règle Sonar githubactions:S7637 : dépendance tierce, + # contrairement à actions/checkout ou actions/upload-artifact, premières parties). + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + enable-cache: true + cache-dependency-glob: apps/backend/uv.lock + # `prune-cache` vaut `true` par défaut (encore sur ce commit) : l'étape de post-job + # « Pruning cache » est restée bloquée 5 minutes avant d'échouer (exit code 2) sur un + # run où les 16 étapes précédentes passaient, sans lien avec le scan. Le prune n'est + # qu'une optimisation de taille de cache entre deux runs, pas une garantie : le + # désactiver retire le blocage sans rien changer au comportement du job. + prune-cache: false + + - name: Installe l'interpréteur déclaré par .python-version + run: uv python install + working-directory: apps/backend + + # `--no-build` : aucune dépendance n'est construite depuis ses sources, donc aucun script de + # build exécuté (règle Sonar S8541). Le projet lui-même n'est pas installé : il tourne depuis + # `apps/backend`, comme dans son Dockerfile. Les `uv run` suivants portent `--frozen + # --no-sync` pour ne rien résoudre ni reconstruire (règle S8544). + - name: Synchronise les dépendances sans dévier du verrou + run: uv sync --frozen --no-dev --no-install-project --no-build + working-directory: apps/backend + + - name: Active TimescaleDB sur la base du scan + run: psql -h localhost -p 5433 -U enervision -d enervision_dast -c "CREATE EXTENSION IF NOT EXISTS timescaledb" + + - name: Applique les migrations + run: uv run --frozen --no-sync --no-build alembic upgrade head + working-directory: apps/backend + + # Sans données, `GET /sites` rend `[]`, chaque `/{site_id}` rend 404 et le scan actif ne + # frappe que des gestionnaires d'erreur plutôt que la logique métier. `db/seeds/` est vide + # (pas encore d'outillage de jeu de données pour la CI) : un site et deux relevés à la main, + # juste assez pour que les routes de lecture aient quelque chose à rendre. + - name: Insère un site et des relevés minimaux pour le scan + run: | + psql -h localhost -p 5433 -U enervision -d enervision_dast <<'SQL' + INSERT INTO site (site_id, site_name, site_type, location, capacity_kw, status) + VALUES ('dast-site', 'Site du scan DAST', 'bureau', 'CI', 50, 'actif') + ON CONFLICT (site_id) DO NOTHING; + + INSERT INTO reading (site_id, timestamp, source, consumption_kw, consumption_kwh, is_working_hours, data_quality, raw_data) + VALUES + ('dast-site', now() - interval '2 hours', 'api_current', 12.5, 12.5, true, 'good', '{}'), + ('dast-site', now() - interval '1 hour', 'api_current', 13.0, 13.0, true, 'good', '{}') + ON CONFLICT DO NOTHING; + SQL + + - name: Démarre l'API + run: | + nohup uv run --frozen --no-sync --no-build uvicorn app.main:create_app --factory \ + --host 0.0.0.0 --port 8000 > "$RUNNER_TEMP/api.log" 2>&1 & + for _ in $(seq 1 30); do + curl -fsS http://localhost:8000/api/v1/health/ready >/dev/null 2>&1 && exit 0 + sleep 2 + done + echo "L'API ne répond pas sur /health/ready" >&2 + cat "$RUNNER_TEMP/api.log" >&2 + exit 1 + working-directory: apps/backend + + - name: Crée le compte lecteur du scan + id: jeton + run: | + jeton="$(../../scripts/dast-token.sh)" + echo "::add-mask::$jeton" + echo "jeton=$jeton" >> "$GITHUB_OUTPUT" + working-directory: apps/backend + + # Étape distincte du scan lui-même, et sans `continue-on-error` : un `curl` qui échoue ici + # (API tombée juste après la sonde de readiness, par exemple) doit rester un échec visible, + # pas se travestir en « ZAP n'a importé aucune URL » à l'étape de garde suivante. + - name: Prépare le contrat pour ZAP + run: | + mkdir -p zap-out zap-logs + curl -fsS http://localhost:8000/openapi.json -o zap-out/openapi.json + # Le dossier passe à l'uid 1000 (utilisateur du conteneur ZAP) : le runner n'y écrit + # plus après ce chown, d'où `zap-logs/` (uid du runner) pour les journaux ci-dessous. + # Pas de `chmod 777` (règle Sonar S2612). + sudo chown -R 1000:1000 zap-out + + # `--network host` : ZAP atteint l'API sur le localhost du runner. + # + # Piège vécu : la clé du nom d'en-tête est `matchstr`, pas `matchstring`. ZAP accepte + # n'importe quelle clé `-config` sans erreur ; avec la mauvaise, il ajoutait à TOUTES les + # requêtes un en-tête au nom vide (`: Bearer `), qu'uvicorn refuse par un 400 + # (« Invalid HTTP request received »), y compris sur les routes publiques. + # + # Le jeton ne passe ni par `${{ }}` dans ce script (il finirait en clair dans le fichier de + # commande que GitHub écrit sur le disque du runner pour toute la durée de l'étape), ni par + # l'argv de `docker run` (visible par `ps aux` et par `docker inspect zap` tant que le + # conteneur existe) : il est écrit dans un fichier de configuration ZAP séparé, monté en + # lecture seule hors de `/zap/wrk` pour ne jamais atterrir dans l'artefact publié. + # + # Les routes d'authentification qui changent l'état du compte du scan sont exclues : un + # scan actif y déclencherait la limitation de débit du login, la réinitialisation de mots de + # passe et la fermeture des sessions, sans rien apprendre de plus. + # + # `scanner.maxScanDurationInMins`/`maxRuleDurationInMins` bornent le scan actif, que `-T` ne + # couvre pas (il ne borne que le démarrage et le scan passif) : sans ça, une règle qui + # traîne peut dépasser le TTL du jeton (401 muets en fin de scan) ou le timeout du job (qui + # annule sans exécuter les étapes `always()`, rapport et journaux perdus). + - name: Scan ZAP + id: zap + continue-on-error: true + env: + JETON: ${{ steps.jeton.outputs.jeton }} + run: | + set -o pipefail + printf 'replacer.full_list(0).description=auth\nreplacer.full_list(0).enabled=true\nreplacer.full_list(0).matchtype=REQ_HEADER\nreplacer.full_list(0).matchstr=Authorization\nreplacer.full_list(0).regex=false\nreplacer.full_list(0).replacement=Bearer %s\n' "$JETON" > "$RUNNER_TEMP/zap-auth.conf" + # Piège vécu : `chmod 600` seul rend le fichier illisible pour le conteneur, qui lit un + # montage bind avec son propre uid (1000), distinct de celui du runner qui l'a écrit. + # ZAP échoue alors dès le lancement (« File not readable: /zap/auth.conf »), et + # `zap-api-scan.py` attend `-T` minutes complètes avant d'abandonner : dix minutes qui + # ressemblent à un scan actif, pour un daemon mort depuis le début. + # + # Piège vécu (numéro deux) : une fois le fichier passé à l'uid 1000 par `sudo chown`, + # l'utilisateur du runner n'en est plus propriétaire et un `chmod` sans `sudo` échoue + # (« Operation not permitted »). Avec le `-e` implicite de bash sur les étapes GitHub + # Actions, cette erreur arrêtait toute l'étape avant même `docker run` : scan « réussi » + # en une fraction de seconde, sans le moindre journal ni rapport produit. + sudo chown 1000:1000 "$RUNNER_TEMP/zap-auth.conf" + sudo chmod 644 "$RUNNER_TEMP/zap-auth.conf" + docker run --name zap --network host \ + -v "$PWD/zap-out:/zap/wrk:rw" \ + -v "$RUNNER_TEMP/zap-auth.conf:/zap/auth.conf:ro" \ + ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \ + -t /zap/wrk/openapi.json -f openapi -O http://localhost:8000 \ + -T 10 \ + -r zap-report.html -J zap-report.json -w zap-report.md \ + -z "-configfile /zap/auth.conf \ + -config globalexcludeurl.url_list.url(0).description=auth-etat \ + -config globalexcludeurl.url_list.url(0).enabled=true \ + -config globalexcludeurl.url_list.url(0).regex='.*/api/v1/auth/(login|password|logout-all|forgot-password|reset-password).*' \ + -config scanner.maxScanDurationInMins=15 \ + -config scanner.maxRuleDurationInMins=5" \ + 2>&1 | tee "$RUNNER_TEMP/zap-stdout.log" + + - name: Récupère les journaux de ZAP + if: always() + run: | + mkdir -p zap-logs + # ZAP journalise la valeur de chaque `-config`/`-configfile` chargé, y compris le jeton, + # à un niveau visible sans `-d` : les copies publiées en artefact sont donc caviardées, + # même si `::add-mask::` (posé à la création du jeton) protège déjà le journal du job. + masque() { sed -E 's/(Bearer )[A-Za-z0-9._-]+/\1[MASQUE]/Ig'; } + [ -f "$RUNNER_TEMP/zap-stdout.log" ] && masque < "$RUNNER_TEMP/zap-stdout.log" > zap-logs/zap-stdout.log + docker cp zap:/home/zap/.ZAP/zap.log "$RUNNER_TEMP/zap-internal.log" 2>/dev/null || true + [ -f "$RUNNER_TEMP/zap-internal.log" ] && masque < "$RUNNER_TEMP/zap-internal.log" > zap-logs/zap.log + [ -f "$RUNNER_TEMP/api.log" ] && masque < "$RUNNER_TEMP/api.log" > zap-logs/api.log + rm -f "$RUNNER_TEMP/zap-auth.conf" + docker rm -f zap >/dev/null 2>&1 || true + + # `continue-on-error` sur le scan ne doit pas faire passer pour vert un scan qui n'a rien + # testé. Constaté une première fois : 2 URL importées sur 26 opérations, ZAP n'avait envoyé + # que des requêtes vouées au 404. Le seuil est dérivé du contrat plutôt que d'un nombre fixe + # : un contrat qui grossit ne doit pas rendre la garde plus permissive qu'elle ne l'était. + - name: Vérifie que le contrat a bien été importé + run: | + attendu="$(python3 -c " + import json + d = json.load(open('zap-out/openapi.json')) + methodes = ('get', 'post', 'put', 'patch', 'delete', 'head', 'options') + print(sum(1 for chemin in d['paths'].values() for m in chemin if m in methodes)) + ")" + minimum=$((attendu * 80 / 100)) + importees="$(sed -n 's/.*Number of Imported URLs: \([0-9]*\).*/\1/p' "$RUNNER_TEMP/zap-stdout.log" | tail -1)" + echo "URL importées depuis le contrat OpenAPI : ${importees:-aucune} (contrat : $attendu opérations, minimum accepté : $minimum)" + if [ "${importees:-0}" -lt "$minimum" ]; then + echo "::error::ZAP n'a importé que ${importees:-0} URL sur $attendu opérations du contrat OpenAPI (minimum attendu : $minimum, soit 80%). Le scan n'a pas testé l'API, voir zap-logs/zap.log dans l'artefact zap-report." + exit 1 + fi + + # Deuxième garde-fou : le contrat peut être importé et ZAP n'obtenir que des erreurs + # (constaté : base sans données, toutes les routes de site répondaient 404). + # + # Piège de conception, trouvé en répétant ce job en local avant de l'écrire ici : borner le + # pourcentage de 4xx ne marche pas. Un scan actif fuzze délibérément un grand nombre + # d'entrées invalides (identifiants inventés, méthodes non supportées...), donc même un scan + # sain, contre l'API seedée juste au-dessus, reste à 98% de 4xx avec seulement 1% de 2xx : + # c'est la forme normale d'un scan actif, pas un signe d'échec. Le signal qui distingue + # vraiment un scan cassé (0% de 2xx, `insight.code.2xx` absent du rapport dans le premier + # incident) d'un scan sain (2xx non nul, aussi faible soit-il) est donc l'absence de succès, + # pas la part d'échecs. Dérivé de `zap-report.json` (champ structuré `insights[]`) plutôt + # que du texte libre du rapport Markdown, qui aurait le même défaut de conception en plus + # d'être fragile au format. + - name: Vérifie que le scan a obtenu au moins une réponse de succès + run: | + python3 - <<'PY' + import json + import sys + + try: + rapport = json.load(open("zap-out/zap-report.json")) + except FileNotFoundError: + print("::error::Aucun rapport ZAP produit : le scan n'a rien testé.") + sys.exit(1) + + pourcentage_2xx = 0.0 + for insight in rapport.get("insights", []): + if insight.get("key") == "insight.code.2xx": + pourcentage_2xx = float(insight.get("statistic", 0)) + break + + print(f"Pourcentage de réponses 2xx : {pourcentage_2xx}%") + if pourcentage_2xx <= 0: + print( + "::error::Aucune réponse 2xx (succès) reçue : le scan n'a atteint aucune route " + "réelle de l'API. Voir zap-logs/api.log et zap-logs/zap.log dans l'artefact " + "zap-report." + ) + sys.exit(1) + PY + + # Uniquement la synthèse (jusqu'à « Alert Detail » exclu) : `$GITHUB_STEP_SUMMARY` est + # limité à 1 Mio, et cette étape tourne sous `always()` - son échec ferait échouer le job + # après le passage des deux garde-fous, pour une simple raison de mise en forme. Le rapport + # complet reste dans l'artefact `zap-report`. + - name: Publie le résumé + if: always() + run: | + if [ -f zap-out/zap-report.md ]; then + awk '/^## Alert Detail/{exit} {print}' zap-out/zap-report.md >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Rapport complet (HTML/JSON/Markdown) dans l'artefact \`zap-report\`." >> "$GITHUB_STEP_SUMMARY" + else + echo "Aucun rapport ZAP produit, voir le journal du job." >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Publie les rapports + if: always() + uses: actions/upload-artifact@v7 + with: + name: zap-report + path: | + zap-out/ + zap-logs/ + if-no-files-found: warn + + # Diagnostic de dernier recours : les journaux de l'API sont déjà dans l'artefact + # (zap-logs/api.log) via l'étape « Récupère les journaux de ZAP » (always()), mais les + # afficher directement dans le journal du job évite d'avoir à le télécharger pour un échec + # évident (l'API n'a jamais démarré, par exemple). + - name: Journal de l'API en cas d'échec + if: failure() || steps.zap.outcome == 'failure' + run: cat "$RUNNER_TEMP/api.log" || true 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 e0deb5d..0e323b8 100644 --- a/docs/architecture/50-cicd.md +++ b/docs/architecture/50-cicd.md @@ -68,23 +68,36 @@ flowchart TB push --> mv & ms push --> av & ab push --> it - push --> sb1 & sb2 --> sscan + push --> sb1 & sb2 & sb3 --> sscan subgraph cd["Déploiement · deploy.yml"] dep["deploy
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 -Les six workflows hébergés par GitHub se déclenchent sur `push` **et** sur `pull_request`, -filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur +Les six workflows hébergés par GitHub qui vérifient le code se déclenchent sur `push` **et** sur +`pull_request`, filtrés par **chemin** : `backend.yml` sur `apps/backend/**`, `frontend.yml` sur `apps/frontend/**`, `ml.yml` sur `ml/**`, `infra.yml` sur `infra/terraform/**`, `airflow.yml` sur `etl/airflow/**` **plus des chemins de `ml/` et de `apps/backend/`**, chacun incluant son propre fichier de workflow dans le filtre pour qu'une modification du pipeline déclenche le pipeline. +`dast.yml` s'en écarte volontairement (détail dans sa propre section plus bas) : aucun +déclenchement sur `push`, seulement `workflow_dispatch`, une planification hebdomadaire, et +`pull_request` restreint à ses deux seuls fichiers. Un scan actif est trop long pour tourner à +chaque commit. + Le filtre d'`airflow.yml` mérite un mot : il inclut `ml/pyproject.toml`, `ml/uv.lock`, `ml/enervision_ml/**`, `apps/backend/pyproject.toml`, `apps/backend/uv.lock` et `apps/backend/app/**` parce que l'image Airflow copie le code et les dépendances des deux @@ -108,7 +121,8 @@ environnement. ## Déploiement -`deploy.yml` est le septième workflow, et le seul qui ne tourne pas chez GitHub : il s'exécute sur +`deploy.yml` est le huitième workflow (`backend`, `frontend`, `ml`, `infra`, `airflow`, +`sonarqube`, `dast`, plus lui-même), et le seul qui ne tourne pas chez GitHub : il s'exécute sur un runner auto-hébergé installé sur la VM ENI, label `eni-g3`, parce que les runners hébergés ne joignent pas une adresse privée d'école. Le runner se connecte en sortie vers GitHub, aucun port entrant n'est ouvert. @@ -254,12 +268,112 @@ Ils ne transitent ni par git ni par GitHub, et le runner, qui travaille dans ce à recevoir. Le revers : ils ne sont sauvegardés nulle part ailleurs. Un `.env` perdu se régénère, ce qui invalide les sessions et les connexions chiffrées par Airflow. +## Scan DAST (OWASP ZAP) + +Statut : `En cours`. Le workflow `dast.yml` attaque l'API **en fonctionnement**, ce que ni Bandit, +ni `pip-audit`, ni Sonar ne font. Il se lance à la main (`workflow_dispatch`), chaque lundi à 3h +UTC, et sur une PR qui modifie le scan lui-même. Pas à chaque PR : un scan actif dure plusieurs +minutes. + +Le job démarre sur le runner la base (même image TimescaleDB que `docker-compose.yml`, base +jetable), applique les migrations, y sème un site et deux relevés (`db/seeds/` est vide, pas +encore d'outillage de jeu de données pour la CI ; sans données, `GET /sites` rend `[]`, chaque +`/{site_id}` rend 404, et le scan actif ne frappe que des gestionnaires d'erreur), démarre le +backend, puis `scripts/dast-token.sh` crée un compte **`lecteur`** et rend son jeton. + +ZAP charge le contrat `/openapi.json` depuis un fichier (`zap-api-scan.py -f openapi -t +/zap/wrk/openapi.json`) et en importe les 26 opérations **quel que soit le jeton** : c'est le +contrat qui décide de ce qui est exploré, pas l'authentification. Le jeton ne change que les +réponses obtenues sur les routes gardées : sans lui, elles répondraient toutes `401` plutôt que +de dérouler leur logique. Huit routes n'exigent aucun jeton porteur (les deux sondes, `login`, +`refresh`, `logout`, `forgot-password`, `reset-password` et `reset-password/validate`) et +répondent donc pareil avec ou sans lui. + +Décisions à savoir défendre : + +- **Le compte du scan est `lecteur`, jamais `admin`.** Un scan actif avec un jeton admin frapperait + `POST /users` et la réinitialisation de mots de passe pour de bon. Le script passe par un admin + jetable pour créer le lecteur (l'API n'a pas d'inscription publique) puis ne s'en sert plus. +- **Un compte neuf est en `must_change_password`**, et toute route gardée le refuse tant que le + mot de passe n'est pas changé. Le script fait ce changement et vérifie `GET /sites` = 200 avant + de rendre le jeton ; sans cela, tout le scan authentifié ne testerait que des `403`. + `POST /auth/password` rend déjà un nouveau jeton valide (l'`iat` tronqué documenté dans + `app/api/deps.py` ne le rejette pas comme antérieur à la session) : le script s'en sert + directement plutôt que de se reconnecter, deux hachages Argon2id (19456 Kio chacun) et deux + allers-retours de refresh-token de moins sur le chemin critique de la CI. +- **`APP_ACCESS_TOKEN_TTL_SECONDS=3600`** (plafond de la configuration) : le jeton par défaut + dure 15 minutes. `scanner.maxScanDurationInMins=15` (ci-dessous) borne le scan actif très en + dessous, marge comprise pour les étapes qui l'entourent. +- **Le jeton ne transite ni par `${{ }}` dans le script de l'étape, ni par l'argv de `docker + run`.** Le premier finirait en clair dans le fichier de commande que GitHub écrit sur le disque + du runner pour toute la durée de l'étape ; le second serait visible par `ps aux` et par + `docker inspect zap` tant que le conteneur existe. Il est écrit dans un fichier de + configuration ZAP séparé (`-configfile`), monté en lecture seule hors de `/zap/wrk` pour ne + jamais atterrir dans l'artefact publié. ZAP journalise malgré tout la valeur de chaque + `-config`/`-configfile` chargé à un niveau visible sans `-d` : les copies de `zap.log` et + `zap-stdout.log` publiées en artefact sont donc caviardées avant publication. + +**Deux pièges d'autorisation** sur ce fichier de configuration (`zap-auth.conf`), tous les deux +propres au montage bind Docker : le conteneur y lit avec son propre uid (1000), distinct de celui +du runner qui l'a écrit, sans remappage automatique. + +- Un `chmod 600` seul rend le fichier illisible pour le conteneur (« File not readable : + /zap/auth.conf »). ZAP échoue dès le lancement, mais `zap-api-scan.py` attend les `-T` minutes + complètes avant d'abandonner : dix minutes qui ressemblent à un scan actif, pour un daemon mort + depuis le début. Corrigé par `sudo chown 1000:1000` du fichier avant de le passer à `644`. +- Ce `chown` déplace la propriété du fichier hors de l'utilisateur du runner : un `chmod` qui + suit sans `sudo` échoue alors (« Operation not permitted »), et le `-e` implicite des étapes + bash de GitHub Actions arrête toute l'étape avant même `docker run` — un scan « réussi » en une + fraction de seconde, sans le moindre journal ni rapport produit. Les deux commandes doivent + passer par `sudo`. + +Les routes d'authentification qui changent l'état du compte (`login`, `password`, `logout-all`, +`forgot-password`, `reset-password`) sont exclues du scan actif : elles y déclencheraient la +limitation de débit et fermeraient les sessions sans rien apprendre de plus. + +**Un scan vert n'est pas un scan qui a testé quelque chose.** Deux garde-fous, eux, **bloquent** : + +- **Moins de 80% des opérations du contrat importées.** Constaté une première fois : 2 URL sur 26 + opérations importées, ZAP n'avait envoyé que des requêtes vouées au 404 (l'analyseur de ZAP + refusait alors le nom accentué d'un des deux schémas de sécurité du contrat, corrigé depuis en + ASCII côté backend). Le seuil est dérivé du contrat (`zap-out/openapi.json`, présent à cette + étape) plutôt que d'un nombre fixe : un contrat qui grossit ne doit pas rendre la garde plus + permissive qu'elle ne l'était. +- **Aucune réponse 2xx.** Constaté une deuxième fois, cause différente : la clé de configuration + du nom d'en-tête pour la règle Replacer est `matchstr`, pas `matchstring` (celui-ci n'existe que + pour le job d'automatisation ZAP, pas pour `-config`) ; ZAP acceptait la mauvaise clé sans + erreur et laissait le nom d'en-tête vide, qu'uvicorn refusait par un `400` sur **toute** requête, + y compris les routes publiques. Piège de conception rencontré en corrigeant cette garde : borner + le *pourcentage* de 4xx ne marche pas, un scan actif fuzze délibérément un grand nombre + d'entrées invalides, si bien qu'un scan sain contre l'API seedée reste à 98% de 4xx avec + seulement 1% de 2xx. C'est la forme normale d'un scan actif. Le signal qui distingue vraiment un + scan cassé (2xx nul, absent du rapport dans les deux incidents) d'un scan sain (2xx non nul, + aussi faible soit-il) est l'absence de succès, pas la part d'échecs. Les deux gardes lisent + `zap-out/zap-report.json` (champs structurés `insights[]`), pas le texte libre du rapport + Markdown. + +Le journal interne de ZAP (`zap.log`) et sa sortie complète (`zap-stdout.log`) sont publiés dans +l'artefact `zap-report` (dossier `zap-logs/`, propriété du runner : `zap-out/` bascule sous l'uid +1000 du conteneur ZAP dès que le contrat y est copié, le runner n'y écrit plus ensuite) pour +diagnostiquer un futur import raté. + +**Non bloquant pour l'instant** (`continue-on-error`, sur la seule étape du scan) pour ce qui est +des alertes elles-mêmes. Le volume d'un premier passage trié est inconnu ; le rapport +HTML/JSON/Markdown est publié en artefact `zap-report`, et sa synthèse (jusqu'aux tableaux +d'alertes, sans le détail par alerte) dans le résumé du job. Fixer un seuil viendra une fois les +alertes triées. + +**Limite à ne pas oublier :** le scan tape la configuration par défaut du backend (`APP_ENV=local`, +pas de TLS, pas de reverse proxy). Il remontera des alertes qui n'existent pas derrière le proxy +(HSTS absent...) et ne dit **rien** des en-têtes ni du TLS que le proxy pose en production. Un +second passage sur la stack complète reste à faire. + ## Ce qui manque, et pourquoi | Manque | Issue | Conséquence assumée | |---|---|---| | Images publiées et promues par digest (GHCR) | aucune | Chaque environnement reconstruit ses images : la production n'exécute pas l'artefact validé en recette, mais un second build du même commit | -| DAST (OWASP ZAP) | #41 | Aucune vérification sur l'application en fonctionnement, seulement sur le code et les dépendances | +| DAST bloquant | #41 | Le scan ZAP existe mais ne bloque rien : aucun seuil n'est fixé tant que les alertes du premier passage ne sont pas triées | | Tests end to end | #46 | Les parcours utilisateur ne sont pas vérifiés en CI | | Tests de charge | #47 | Aucun garde-fou de performance | | Scan d'image de conteneur | aucune | Les `Dockerfile` sont construits en local, pas analysés | 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..2bbbdfb --- /dev/null +++ b/scripts/dast-token.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Prépare le scan DAST : crée un compte `lecteur` sur une API déjà démarrée, lui fait passer le +# changement de mot de passe obligatoire, et écrit son jeton d'accès sur la sortie standard. +# +# Piège : un compte neuf est en `must_change_password`, et toute route gardée le refuse tant que +# le mot de passe n'a pas été changé. Sans cette étape, ZAP ne verrait que 403 sur les routes +# gardées et le scan ne testerait rien de l'API authentifiée. +# +# Contrainte : le compte du scan est `lecteur`, jamais `admin`. Un scan actif avec un jeton admin +# frapperait POST /users ou la réinitialisation de mots de passe pour de bon. +# +# L'administrateur n'existe que pour créer ce compte (l'API n'a pas d'inscription publique). +# À lancer depuis apps/backend, dans un environnement où DATABASE_URL et APP_SECRET_KEY visent +# une base JETABLE : le script y crée deux comptes. + +set -euo pipefail + +BASE_URL="${BASE_URL:-http://localhost:8000}" +API="$BASE_URL/api/v1" +SUFFIXE="$(openssl rand -hex 4)" +EMAIL_ADMIN="dast-admin-$SUFFIXE@enervision.fr" +EMAIL_LECTEUR="dast-lecteur-$SUFFIXE@enervision.fr" + +# Classes exigées par le validateur : majuscule, minuscule, chiffre, caractère spécial. +nouveau_mot_de_passe() { echo "Dast-$(openssl rand -hex 12)-Aa1!"; } + +# Tout ce qui n'est pas la sortie finale part sur stderr : la sortie standard ne porte que le jeton. +journal() { echo "dast-token: $*" >&2; } + +connexion() { + local email="$1" mot_de_passe="$2" + curl -fsS -X POST "$API/auth/login" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$email" --arg p "$mot_de_passe" '{email:$e, password:$p}')" \ + | jq -r '.access_token' +} + +# Rend le nouveau jeton d'accès : `/auth/password` en émet un (avec l'`iat` de la session en +# cours, cf. le piège documenté dans `app/api/deps.py`), pas seulement une confirmation. S'y fier +# évite une reconnexion, donc un second hachage Argon2id (19456 Kio) et un aller-retour de +# refresh-token superflus sur le chemin critique de la CI. +changer_mot_de_passe() { + local jeton="$1" ancien="$2" nouveau="$3" + curl -fsS -X POST "$API/auth/password" \ + -H "Authorization: Bearer $jeton" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg a "$ancien" --arg n "$nouveau" '{current_password:$a, new_password:$n}')" \ + | jq -r '.access_token' +} + +journal "création de l'administrateur $EMAIL_ADMIN" +if ! SORTIE="$(uv run --frozen --no-sync --no-build python -m app.cli create-admin --email "$EMAIL_ADMIN" --generate)"; then + journal "la création de l'administrateur a échoué :" + journal "$SORTIE" + exit 1 +fi +MDP_ADMIN="$(sed -n 's/^Mot de passe généré, il ne sera plus affiché : //p' <<<"$SORTIE")" +[[ -n "$MDP_ADMIN" ]] || { journal "mot de passe administrateur introuvable dans la sortie :"; journal "$SORTIE"; exit 1; } + +JETON="$(connexion "$EMAIL_ADMIN" "$MDP_ADMIN")" +NOUVEAU_ADMIN="$(nouveau_mot_de_passe)" +JETON="$(changer_mot_de_passe "$JETON" "$MDP_ADMIN" "$NOUVEAU_ADMIN")" + +journal "création du lecteur $EMAIL_LECTEUR" +REPONSE="$(curl -fsS -X POST "$API/users" -H "Authorization: Bearer $JETON" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg e "$EMAIL_LECTEUR" '{email:$e, role:"lecteur"}')")" +MDP_TEMPORAIRE="$(jq -r '.temporary_password // empty' <<<"$REPONSE")" +[[ -n "$MDP_TEMPORAIRE" ]] || { journal "mot de passe temporaire introuvable dans la réponse de POST /users :"; journal "$REPONSE"; exit 1; } + +JETON="$(connexion "$EMAIL_LECTEUR" "$MDP_TEMPORAIRE")" +NOUVEAU_LECTEUR="$(nouveau_mot_de_passe)" +JETON="$(changer_mot_de_passe "$JETON" "$MDP_TEMPORAIRE" "$NOUVEAU_LECTEUR")" + +# Vérifie que le jeton ouvre bien une route gardée avant de le rendre. +CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$API/sites" -H "Authorization: Bearer $JETON")" +[[ "$CODE" == "200" ]] || { journal "GET /sites répond $CODE avec le jeton du lecteur, attendu 200"; exit 1; } + +journal "jeton du lecteur prêt" +echo "$JETON"