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 # É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 - 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 - 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 # `--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`). # # 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. # # 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: | 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" \ ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py \ -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 \ -config replacer.full_list(0).enabled=true \ -config replacer.full_list(0).matchtype=REQ_HEADER \ -config replacer.full_list(0).matchstr=Authorization \ -config replacer.full_list(0).regex=false \ -config replacer.full_list(0).replacement='Bearer ${{ steps.jeton.outputs.jeton }}' \ -config globalexcludeurl.url_list.url(0).description=auth-etat \ -config globalexcludeurl.url_list.url(0).enabled=true \ -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: | 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 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' "$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-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 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 - 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/ 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. - name: Journal de l'API en cas d'échec if: failure() || steps.zap.outcome == 'failure' run: cat "$RUNNER_TEMP/api.log" || true