From 4e8816a46854752f4625dc112fe5ce1801186e3a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 6 Aug 2026 01:14:16 +0200 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20rem=C3=A9diation=20compl=C3=A8te=20d?= =?UTF-8?q?e=20l'audit=20de=20s=C3=A9curit=C3=A9=20et=20de=20qualit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige les défauts relevés par l'audit du 2026-08-04 (9 dimensions, contre-expertise adversariale). 12 lots, périmètres de fichiers disjoints. Bloquants - MFA: la connexion à deux étapes était cassée pour TOUS les comptes MFA. Le template rendait {{.Password}}, clé absente de la map de données, donc un mot de passe vide au second POST -> "Identifiants invalides" + compteur de rate-limit. Remplacé par un état de pré-authentification côté serveur (mfa_pending_user + expiration 5 min), sans credential dans le HTML. - CI: les PR d'un dépôt public s'exécutaient sur le runner self-hosted (go test = exécution de code arbitraire). Bascule sur ubuntu-latest pour les PR. Injection shell via le titre de PR corrigée (passage par env). - Publication GHCR enchaînée à la release (l'image latest avait 4 versions de retard sur le dépôt). - Restauration N3: le verdict "application répond" était rendu sans exécuter de healthcheck. Le niveau prouvé est désormais calculé avant restauration. - Canal GoaBackup: la commande d'installation téléchargeait la page de login chez un tiers. Route publique + jeton à usage unique. - Audit: aucune action privilégiée n'était tracée (shell root, clés SSH, playbooks, MFA). Middleware AuditTrail + appels explicites. - XSS stockée dans la vue Wazuh (nom d'agent, IP, OS non échappés). - Ansible contournait l'épinglage TOFU appliqué par le reste du produit. Sécurité - Rate-limiter: X-Forwarded-For n'est plus cru sans TRUSTED_PROXIES; purge des entrées. - Révocation de session par session_epoch; secours MFA par reset admin. - RBAC: les mutations du catalogue d'applications passent en AdminOnly. - SSRF du worker de health-check fermée au niveau du dialer. - /setup: limitation de débit et création de compte atomique. Données - Migrations versionnées, erreurs réelles distinguées par code MySQL, Migrate() retourne une erreur et peut faire échouer le démarrage. - retention_count effectivement appliqué; rpo_hours configurable; l'archive restaurée est enregistrée (preuve auditable). Frontend - 497 classes de palette brute -> tokens sémantiques (thème clair illisible). - Config Tailwind : 61 utilitaires d'opacité étaient omis du build. CSS régénéré et compilé dans l'image. - 31 alert() -> composant toast partagé; modales accessibles; mobile. Exploitabilité - /healthz et /readyz; WriteTimeout neutralisé sur les endpoints streamés; certificat TLS persisté avec SAN configurables; support des secrets _FILE; durcissement des conteneurs. Dépendances - x/crypto v0.31.0 -> v0.52.0 (6 vulns, dont GO-2026-5020 atteignable depuis la console SSH), chi v5.2.1 -> v5.2.2. - govulncheck: 42 -> 26 vulnérabilités appelées, plus aucune hors stdlib. Couverture de tests: 14,3% -> 24,1%. go build, go vet, gofmt et go test -race passent sur l'intégralité du dépôt. --- .env.example | 53 +++ .github/workflows/ci-deploy.yml | 238 +++++++++- .github/workflows/publish-image.yml | 63 ++- CONTRIBUTING.md | 36 +- Dockerfile | 62 ++- ansible.cfg | 7 +- assets/dom_xss_test.go | 115 +++++ assets/static/search.js | 28 +- assets/static/sw.js | 16 - assets/static/tailwind.min.css | 2 +- assets/static/theme.css | 52 +-- assets/static/theme.js | 64 ++- assets/static/ui.js | 286 ++++++++++++ assets/templates/add_app.html | 10 +- assets/templates/ansible.html | 195 ++++---- assets/templates/audit_logs.html | 49 ++- assets/templates/backups.html | 200 ++++----- assets/templates/console.html | 25 +- assets/templates/dashboard.html | 90 ++-- assets/templates/login.html | 24 +- assets/templates/onboarding-canal.html | 49 ++- assets/templates/onboarding-proxmox.html | 39 +- assets/templates/overview.html | 170 +++++-- assets/templates/partials.html | 122 ++++- assets/templates/proxmox.html | 173 +++++--- assets/templates/report.html | 14 + assets/templates/settings-account.html | 53 +-- assets/templates/settings-partials.html | 26 +- assets/templates/settings-services.html | 67 +-- assets/templates/settings.html | 13 +- assets/templates/setup.html | 19 +- assets/templates/soar.html | 53 +-- assets/templates/ssh_keys.html | 141 +++--- assets/templates/wazuh.html | 222 +++++----- assets/ui_regression_test.go | 415 ++++++++++++++++++ cmd/server/main.go | 15 + docker-compose-dev.yml | 43 +- docker-compose.yml | 116 ++++- docker-entrypoint.sh | 41 ++ go.mod | 15 +- go.sum | 32 +- install/.env.example | 55 +++ install/backup.sh | 164 +++++++ install/docker-compose.yml | 99 ++++- internal/config/config.go | 109 ++++- internal/config/secret_file_test.go | 133 ++++++ internal/database/database.go | 393 ++++++++++++++--- internal/database/database_test.go | 320 ++++++++++++++ internal/handlers/ansible.go | 18 + internal/handlers/audit_trail_test.go | 131 ++++++ internal/handlers/auth.go | 358 ++++++++++++--- internal/handlers/auth_mfa_flow_test.go | 360 +++++++++++++++ internal/handlers/auth_setup_test.go | 105 +++++ internal/handlers/authfakedb_test.go | 276 ++++++++++++ internal/handlers/backups.go | 14 + internal/handlers/console.go | 24 + internal/handlers/health.go | 100 +++++ internal/handlers/health_probe_test.go | 155 +++++++ internal/handlers/mfa.go | 69 ++- internal/handlers/mfa_recovery_test.go | 236 ++++++++++ internal/handlers/onboarding_channel.go | 322 ++++++++++++-- .../handlers/onboarding_channel_script.go | 24 +- internal/handlers/onboarding_channel_test.go | 179 +++++++- internal/handlers/search.go | 44 +- internal/handlers/search_rbac_test.go | 142 ++++++ internal/handlers/ssh.go | 39 ++ internal/handlers/users.go | 45 ++ internal/middleware/audit.go | 160 +++++++ internal/middleware/audit_test.go | 242 ++++++++++ internal/middleware/auth.go | 17 +- internal/middleware/auth_epoch_test.go | 213 +++++++++ internal/middleware/ratelimit.go | 147 ++++++- internal/middleware/ratelimit_test.go | 10 +- internal/middleware/ratelimit_trust_test.go | 195 ++++++++ internal/models/backup.go | 8 +- internal/router/router.go | 60 ++- internal/router/router_rbac_test.go | 220 +++++++++- internal/server/server.go | 62 ++- internal/server/tls.go | 234 +++++++++- internal/services/ansible.go | 299 ++++++++++++- internal/services/ansible_test.go | 272 ++++++++++++ internal/services/audit.go | 10 +- internal/services/backup.go | 227 +++++++++- internal/services/backup_test.go | 117 +++++ internal/services/proxmox_channel.go | 159 +++++-- internal/services/proxmox_channel_test.go | 118 +++++ internal/services/proxmox_restore.go | 95 +++- internal/services/proxmox_restore_test.go | 95 +++- internal/services/restore_engine.go | 202 +++++++-- internal/services/restore_engine_test.go | 175 ++++++++ internal/services/wazuh_indexer.go | 152 +++++-- internal/services/wazuh_indexer_test.go | 184 ++++++++ internal/workers/ansible_claim_test.go | 285 ++++++++++++ internal/workers/ansible_scheduler.go | 126 +++++- internal/workers/health.go | 208 ++++++++- internal/workers/health_ssrf_test.go | 158 +++++++ schema.sql | 191 +++++++- tailwind.config.js | 86 ++-- tailwind.input.css | 283 ++++++++---- 99 files changed, 10943 insertions(+), 1434 deletions(-) create mode 100644 assets/dom_xss_test.go delete mode 100644 assets/static/sw.js create mode 100644 assets/static/ui.js create mode 100644 assets/ui_regression_test.go create mode 100755 docker-entrypoint.sh create mode 100755 install/backup.sh create mode 100644 internal/config/secret_file_test.go create mode 100644 internal/database/database_test.go create mode 100644 internal/handlers/audit_trail_test.go create mode 100644 internal/handlers/auth_mfa_flow_test.go create mode 100644 internal/handlers/auth_setup_test.go create mode 100644 internal/handlers/authfakedb_test.go create mode 100644 internal/handlers/health.go create mode 100644 internal/handlers/health_probe_test.go create mode 100644 internal/handlers/mfa_recovery_test.go create mode 100644 internal/handlers/search_rbac_test.go create mode 100644 internal/middleware/audit.go create mode 100644 internal/middleware/audit_test.go create mode 100644 internal/middleware/auth_epoch_test.go create mode 100644 internal/middleware/ratelimit_trust_test.go create mode 100644 internal/services/ansible_test.go create mode 100644 internal/services/wazuh_indexer_test.go create mode 100644 internal/workers/ansible_claim_test.go create mode 100644 internal/workers/health_ssrf_test.go diff --git a/.env.example b/.env.example index 84a4309..3ac544c 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,8 @@ # L'app REFUSE de démarrer si elle est vide, trop courte (< 32 caractères) ou # égale à un placeholder connu. Générez une valeur forte : # openssl rand -hex 32 +# À SAUVEGARDER avec la base : un dump restauré avec un autre SESSION_SECRET rend +# tous les secrets stockés indéchiffrables (voir install/backup.sh). SESSION_SECRET= # Identifiants MySQL. Choisissez des mots de passe FORTS (ne laissez pas de @@ -27,10 +29,61 @@ DB_PASSWORD= DB_ROOT_PASSWORD= DB_NAME=goacloud +# ---------------------------------------------------------------------------- +# RECOMMANDÉ — secrets par fichier (convention Docker « _FILE ») +# ---------------------------------------------------------------------------- +# Tout secret listé ci-dessous accepte une variante _FILE qui pointe vers un +# fichier : GoaCore lit alors le contenu du fichier (retour à la ligne final +# ignoré) au lieu de la variable d'environnement. C'est ce qu'il faut faire dès +# que d'autres personnes ont accès au démon Docker : une variable d'environnement +# est lisible par `docker inspect` et dans /proc//environ, un fichier monté +# depuis un secret Docker/Swarm ou un tmpfs ne l'est pas. +# +# Secrets concernés : SESSION_SECRET, DB_PASS (hôte : DB_PASSWORD), +# PROXMOX_TOKEN_SECRET, WAZUH_PASSWORD, WAZUH_INDEXER_PASSWORD, AI_API_KEY, +# DISCORD_BOT_TOKEN. +# +# Le _FILE l'emporte sur la variable simple, et un fichier absent ou vide +# REFUSE le démarrage (pas de retour silencieux à une valeur par défaut). +# +# Exemple : décommentez le montage « ./secrets:/run/secrets:ro » dans +# docker-compose.yml, puis : +# mkdir -p secrets && openssl rand -hex 32 > secrets/session_secret +# chmod 600 secrets/* +# SESSION_SECRET_FILE=/run/secrets/session_secret +# DB_PASSWORD_FILE=/run/secrets/db_password +# +# SESSION_SECRET_FILE= +# DB_PASSWORD_FILE= +# PROXMOX_TOKEN_SECRET_FILE= +# WAZUH_PASSWORD_FILE= +# WAZUH_INDEXER_PASSWORD_FILE= +# AI_API_KEY_FILE= +# DISCORD_BOT_TOKEN_FILE= +# DB_ROOT_PASSWORD_FILE= # conteneur MySQL : EXCLUSIF de DB_ROOT_PASSWORD + # ---------------------------------------------------------------------------- # OPTIONNEL — serveur / TLS (defaults sains, à ne modifier qu'au besoin) # ---------------------------------------------------------------------------- +# Noms DNS et IP couverts par le certificat auto-signé (séparés par des virgules). +# À RENSEIGNER : sans eux, le certificat ne vaut que pour localhost/127.0.0.1 et +# le navigateur affiche une erreur de certificat à chaque accès. Mettez ici le nom +# (et/ou l'IP) par lequel vous ouvrez GoaCore. +# TLS_HOSTS=core.example.com,192.168.20.11 +TLS_HOSTS= + +# Le certificat auto-signé est PERSISTÉ (volume goacore_tls monté sur /app/certs) +# et n'est régénéré que s'il est absent, expiré, ou s'il ne couvre plus TLS_HOSTS : +# son empreinte ne change donc plus à chaque mise à jour. Emplacement modifiable : +# TLS_DIR=certs + +# Fournir SON PROPRE certificat (Let's Encrypt, PKI interne…) : renseignez les +# DEUX variables et montez les fichiers en lecture seule dans le conteneur. +# GoaCore ne les écrase jamais et refuse de démarrer s'ils sont illisibles. +# TLS_CERT_FILE=/run/tls/fullchain.pem +# TLS_KEY_FILE=/run/tls/privkey.pem + # Cookies de session marqués Secure (HTTPS). Laisser "true" en production. # COOKIE_SECURE=true diff --git a/.github/workflows/ci-deploy.yml b/.github/workflows/ci-deploy.yml index bdeab60..9155d39 100644 --- a/.github/workflows/ci-deploy.yml +++ b/.github/workflows/ci-deploy.yml @@ -1,31 +1,79 @@ name: CI & Deploy +# SÉCURITÉ — le dépôt est PUBLIC et le runner `self-hosted` vit sur le LAN de +# l'auteur (socket Docker, coffre de secrets, accès aux services internes). Le code +# d'une Pull Request — potentiellement issue d'un fork inconnu, cf. CONTRIBUTING.md — +# ne doit JAMAIS s'y exécuter. Tous les jobs déclenchés par une PR tournent donc sur +# un runner GitHub hébergé et jetable ; seuls les `push` sur dev/main (du code déjà +# mergé, donc relu) atteignent le self-hosted. +# +# Corollaire : aucune valeur contrôlée par un tiers (titre/corps de PR, nom de +# branche, acteur, message de commit) n'est interpolée directement dans un `run:` — +# l'expansion `${{ }}` est textuelle et précède l'analyse par bash, donc un titre de +# PR bien choisi refermerait la chaîne et exécuterait sa charge. Ces valeurs passent +# exclusivement par des variables d'environnement, citées et assainies. + on: push: branches: [dev, main] pull_request: branches: [main] +# Moindre privilège par défaut ; les jobs qui écrivent (release, publish) le +# redemandent explicitement. +permissions: + contents: read + jobs: build: name: Build & Test - runs-on: self-hosted + # PR (code non relu) -> runner jetable GitHub ; push interne -> self-hosted. + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - - uses: actions/setup-go@v6 + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.22' - name: Download dependencies run: go mod download + # CONTRIBUTING.md annonce `gofmt -l .` comme faisant partie de la CI : cette + # étape rend l'affirmation vraie. gofmt n'ayant pas de code de sortie d'échec, + # on transforme sa sortie en échec explicite. + - name: Format (gofmt) + run: | + UNFORMATTED=$(gofmt -l .) + if [ -n "$UNFORMATTED" ]; then + echo "::error::Fichiers non formatés (lancer 'gofmt -w') :" + echo "$UNFORMATTED" + exit 1 + fi + - name: Vet run: go vet ./... - name: Test run: go test ./... + # Le produit est concurrent (workers, scheduler, cache SOAR) : une data race + # non détectée se paie en production. Séparé du run normal pour distinguer + # « test cassé » de « race détectée » dans les logs. + - name: Test (race detector) + run: go test ./... -race + + # TODO(sécurité) : retirer `continue-on-error` dès que la mise à jour des + # dépendances est mergée. govulncheck remonte aujourd'hui des vulnérabilités + # effectivement appelées ; l'étape est volontairement non bloquante pour ne pas + # geler la CI avant ce correctif, mais elle DOIT devenir bloquante ensuite — + # sans quoi elle ne sert à rien. + - name: Vulnérabilités (govulncheck) + continue-on-error: true + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + "$(go env GOPATH)/bin/govulncheck" ./... + - name: Build run: CGO_ENABLED=0 go build -o bin/goacloud ./cmd/server @@ -40,9 +88,11 @@ jobs: # NEVER touches a real host. helper: name: Validate goabackup helper - runs-on: self-hosted + # `test-runner.sh` vient de la PR : il ne s'exécute jamais sur le self-hosted + # pour une PR (cf. bandeau sécurité en tête de fichier). + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-latest' || 'self-hosted' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Bash syntax (bash -n) run: bash -n deploy/goabackup/goabackup-runner.sh @@ -72,22 +122,51 @@ jobs: docker rmi goacloud-dev-app 2>/dev/null || true docker builder prune -af 2>/dev/null || true + # Un webhook révoqué ou un hôte à court de disque répond 4xx/5xx : sans + # vérification du code HTTP la CI resterait verte alors que rien n'est déployé. - name: Trigger Portainer (DEV) + env: + WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK_URL_DEV }} + run: | + BODY=$(mktemp) + CODE=$(curl -sk -o "$BODY" -w '%{http_code}' --max-time 300 \ + -X POST "$WEBHOOK" -H "Content-Type: application/json" -d '{}') + if [ "$CODE" -lt 200 ] || [ "$CODE" -ge 300 ]; then + echo "::error::Webhook Portainer DEV : HTTP $CODE — déploiement NON effectué" + head -c 500 "$BODY" + exit 1 + fi + echo "Webhook Portainer DEV accepté (HTTP $CODE)" + + - name: Vérifier que DEV répond + env: + PROBE_URL: ${{ vars.DEV_PROBE_URL || 'https://devcore.goacloud.fr/' }} run: | - curl -sk -X POST "${{ secrets.PORTAINER_WEBHOOK_URL_DEV }}" \ - -H "Content-Type: application/json" -d '{}' + for i in $(seq 1 30); do + CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 5 "$PROBE_URL" || echo 000) + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 400 ]; then + echo "DEV répond (HTTP $CODE) après $i tentative(s)" + exit 0 + fi + sleep 5 + done + echo "::error::DEV ne répond pas après 150s (dernier code : $CODE)" + exit 1 - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} COMMIT_MSG: ${{ github.event.head_commit.message }} + ACTOR: ${{ github.actor }} run: | - SAFE_MSG=$(echo "$COMMIT_MSG" | head -1 | tr -d '"\\' | cut -c1-100) + export LC_ALL=C.UTF-8 + SAFE_MSG=$(printf '%s' "$COMMIT_MSG" | head -1 | tr -d '"\\' | tr -d '\000-\037' | cut -c1-100) + SAFE_ACTOR=$(printf '%s' "$ACTOR" | tr -cd 'A-Za-z0-9._-' | cut -c1-64) SHORT="${GITHUB_SHA:0:7}" curl -sf -H "Content-Type: application/json" -X POST "$DISCORD_WEBHOOK" -d "{ \"embeds\": [{ \"title\": \"GoaCore DEV - Deploy OK\", - \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** ${{ github.actor }}\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://devcore.goacloud.fr\", + \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** $SAFE_ACTOR\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://devcore.goacloud.fr\", \"color\": 3447003, \"footer\": {\"text\": \"Branche dev | Self-hosted runner\"} }] @@ -99,7 +178,7 @@ jobs: runs-on: self-hosted if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 # The Portainer webhook re-pulls the repo then runs compose pull -> build -> up. # The app service is build-only (no pinned `image:` registry tag — see @@ -107,22 +186,56 @@ jobs: # abort that previously killed every prod redeploy), Portainer rebuilds it from # source and `up -d` recreates the container with the new code. The db container # and ALL named volumes (MySQL data) are preserved. No pre-build / no rm needed. + # + # Le code HTTP est vérifié : un webhook révoqué ou un hôte plein renvoie 5xx et + # doit faire échouer le job. Sans ça la CI reste verte, Discord annonce + # « Deploy OK », la version est taguée — et la prod tourne sur l'ancienne image. - name: Trigger Portainer (PROD) + env: + WEBHOOK: ${{ secrets.PORTAINER_WEBHOOK_URL_PROD }} run: | - curl -sk -X POST "${{ secrets.PORTAINER_WEBHOOK_URL_PROD }}" \ - -H "Content-Type: application/json" -d '{}' + BODY=$(mktemp) + CODE=$(curl -sk -o "$BODY" -w '%{http_code}' --max-time 300 \ + -X POST "$WEBHOOK" -H "Content-Type: application/json" -d '{}') + if [ "$CODE" -lt 200 ] || [ "$CODE" -ge 300 ]; then + echo "::error::Webhook Portainer PROD : HTTP $CODE — déploiement NON effectué" + head -c 500 "$BODY" + exit 1 + fi + echo "Webhook Portainer PROD accepté (HTTP $CODE)" + + # Le webhook est asynchrone : un 2xx signifie « pris en compte », pas + # « en ligne ». On sonde l'application jusqu'à ce qu'elle réponde (2xx/3xx — + # `/` redirige vers /login quand la session est absente). + - name: Vérifier que PROD répond + env: + PROBE_URL: ${{ vars.PROD_PROBE_URL || 'https://core.goacloud.fr/' }} + run: | + for i in $(seq 1 30); do + CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 5 "$PROBE_URL" || echo 000) + if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 400 ]; then + echo "PROD répond (HTTP $CODE) après $i tentative(s)" + exit 0 + fi + sleep 5 + done + echo "::error::PROD ne répond pas après 150s (dernier code : $CODE)" + exit 1 - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} COMMIT_MSG: ${{ github.event.head_commit.message }} + ACTOR: ${{ github.actor }} run: | - SAFE_MSG=$(echo "$COMMIT_MSG" | head -1 | tr -d '"\\' | cut -c1-100) + export LC_ALL=C.UTF-8 + SAFE_MSG=$(printf '%s' "$COMMIT_MSG" | head -1 | tr -d '"\\' | tr -d '\000-\037' | cut -c1-100) + SAFE_ACTOR=$(printf '%s' "$ACTOR" | tr -cd 'A-Za-z0-9._-' | cut -c1-64) SHORT="${GITHUB_SHA:0:7}" curl -sf -H "Content-Type: application/json" -X POST "$DISCORD_WEBHOOK" -d "{ \"embeds\": [{ \"title\": \"GoaCore PROD - Deploy OK\", - \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** ${{ github.actor }}\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://core.goacloud.fr\", + \"description\": \"**Commit:** \`$SHORT\`\\n**Auteur:** $SAFE_ACTOR\\n**Message:** $SAFE_MSG\\n\\n:globe_with_meridians: https://core.goacloud.fr\", \"color\": 3066993, \"footer\": {\"text\": \"Branche main | Self-hosted runner\"} }] @@ -135,12 +248,19 @@ jobs: if: github.ref == 'refs/heads/main' && github.event_name == 'push' permissions: contents: write + outputs: + # Consommés par le job `publish` : un tag poussé avec GITHUB_TOKEN ne + # redéclenche AUCUN workflow (garde anti-boucle GitHub), donc publish-image.yml + # ne se réveille pas tout seul — c'est pour ça qu'on chaîne la publication ici. + tag: ${{ steps.release.outputs.tag }} + created: ${{ steps.release.outputs.created }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 - name: Determine version bump and create release + id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} @@ -194,6 +314,7 @@ jobs: # Skip if tag already exists if git rev-parse "$NEW_TAG" >/dev/null 2>&1; then echo "Tag $NEW_TAG already exists, skipping release" + echo "created=false" >> "$GITHUB_OUTPUT" exit 0 fi @@ -231,6 +352,12 @@ jobs: echo "Release $NEW_TAG created successfully" + # Passe la main au job `publish` (image GHCR). + { + echo "tag=$NEW_TAG" + echo "created=true" + } >> "$GITHUB_OUTPUT" + # Notify Discord RELEASE_URL="https://github.com/${REPO}/releases/tag/${NEW_TAG}" if [ -n "$DISCORD_WEBHOOK" ]; then @@ -270,20 +397,93 @@ jobs: }" || true fi + # Publication de l'image publique ghcr.io/abrahamop/goacore DANS LE MÊME RUN que la + # release. Le tag créé ci-dessus l'est avec GITHUB_TOKEN : GitHub ne redéclenche + # aucun workflow sur un événement produit par GITHUB_TOKEN, donc publish-image.yml + # (sur `push: tags`) ne partait jamais — l'image GHCR était figée plusieurs semaines + # derrière le dépôt alors que le README promet une image publiée à chaque version. + # publish-image.yml reste utile pour republier un tag à la main (workflow_dispatch). + publish: + name: Publish image (GHCR) + needs: release + if: needs.release.outputs.created == 'true' + # Runner GitHub : buildx + QEMU y sont disponibles (build multi-arch), et le + # self-hosted n'a pas à porter ce build. + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout (tag de la release) + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + ref: ${{ needs.release.outputs.tag }} + + - name: Set up QEMU (émulation arm64) + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + + - name: Set up Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + + - name: Login GHCR + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # `value=` est explicite : le run tourne sur un ref de branche (main), pas sur + # le ref du tag, donc `type=semver` ne peut pas le déduire seul. `latest` est + # légitime ici : on vient tout juste de tagger la release depuis main. + - name: Métadonnées (tags + labels) + id: meta + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 + with: + images: ghcr.io/abrahamop/goacore + tags: | + type=semver,pattern={{version}},value=${{ needs.release.outputs.tag }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.release.outputs.tag }} + type=raw,value=latest + + - name: Build & push (amd64 + arm64) + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + notify-pr: name: PR Check needs: [build, helper] - runs-on: self-hosted - if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + # Une PR de fork n'a de toute façon pas accès aux secrets : le webhook serait + # vide et l'étape échouerait bruyamment pour rien. On ne notifie que les PR + # internes. + if: >- + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository steps: - name: Notify Discord env: DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + # Valeurs contrôlées par l'auteur de la PR : jamais interpolées dans le + # script, uniquement lues (et assainies) depuis l'environnement. + PR_TITLE: ${{ github.event.pull_request.title }} + PR_NUMBER: ${{ github.event.pull_request.number }} + ACTOR: ${{ github.actor }} run: | + export LC_ALL=C.UTF-8 + SAFE_TITLE=$(printf '%s' "$PR_TITLE" | tr -d '"\\' | tr -d '\000-\037' | cut -c1-120) + SAFE_ACTOR=$(printf '%s' "$ACTOR" | tr -cd 'A-Za-z0-9._-' | cut -c1-64) + SAFE_NUMBER=$(printf '%s' "$PR_NUMBER" | tr -cd '0-9' | cut -c1-12) curl -sf -H "Content-Type: application/json" -X POST "$DISCORD_WEBHOOK" -d "{ \"embeds\": [{ - \"title\": \"GoaCloud - PR #${{ github.event.pull_request.number }} prete\", - \"description\": \"**PR:** ${{ github.event.pull_request.title }}\\n**Auteur:** ${{ github.actor }}\\n\\nBuild OK, pret a merge dans main (PROD).\", + \"title\": \"GoaCloud - PR #${SAFE_NUMBER} prete\", + \"description\": \"**PR:** $SAFE_TITLE\\n**Auteur:** $SAFE_ACTOR\\n\\nBuild OK, pret a merge dans main (PROD).\", \"color\": 15844367, \"footer\": {\"text\": \"Pull Request dev -> main\"} }] diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 37ca0dc..91d2172 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -1,8 +1,16 @@ name: Publish image (GHCR) # Publie l'image Docker publique ghcr.io/abrahamop/goacore pour faciliter -# l'installation (docker compose up sans build). Déclenché par un tag de version -# (vX.Y.Z) ou manuellement. Indépendant du CI de déploiement maison (ci-deploy.yml). +# l'installation (docker compose up sans build). +# +# CHEMIN NOMINAL : la publication est chaînée à la release, dans ci-deploy.yml +# (job `publish`, `needs: release`). Un tag poussé avec GITHUB_TOKEN ne redéclenche +# aucun workflow — ce fichier ne se réveillait donc jamais sur les auto-releases, +# et l'image GHCR est restée des semaines derrière le dépôt. +# +# CE WORKFLOW EST LE FILET DE SECOURS : republier un tag à la main +# (workflow_dispatch en se plaçant sur le tag), ou publier un tag poussé +# manuellement (PAT / clé de déploiement), hors du chemin d'auto-release. on: push: tags: @@ -18,16 +26,53 @@ jobs: runs-on: ubuntu-latest # runner GitHub (gratuit sur repo public) : buildx + QEMU dispo steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 # historique + tags requis par la vérification `latest` ci-dessous + + # `latest` doit désigner la dernière version publiée de `main`, et rien + # d'autre. Auparavant il était posé inconditionnellement : un + # workflow_dispatch lancé depuis un vieux tag faisait RECULER `latest`, et + # chaque nouvel installateur suivant le README récupérait une version + # périmée. On ne le pose donc que si le ref est bien un tag de version, + # atteignable depuis `main`, et que c'est le plus récent de `main`. + - name: Faut-il poser le tag `latest` ? + id: latest + run: | + if [ "$GITHUB_REF_TYPE" != "tag" ]; then + echo "Ref '$GITHUB_REF_NAME' n'est pas un tag → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + # En cas de doute (fetch impossible, tag hors de main), on s'abstient : + # ne pas poser 'latest' est toujours moins grave que le faire reculer. + if ! git fetch --quiet --tags origin '+refs/heads/main:refs/remotes/origin/main'; then + echo "::warning::Impossible de récupérer main → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if ! git merge-base --is-ancestor "$GITHUB_REF_NAME" origin/main; then + echo "Tag $GITHUB_REF_NAME absent de main → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + NEWEST=$(git tag --list 'v*.*.*' --merged origin/main --sort=-v:refname | head -1) + if [ "$NEWEST" = "$GITHUB_REF_NAME" ]; then + echo "$GITHUB_REF_NAME est la dernière version de main → 'latest' posé" + echo "value=true" >> "$GITHUB_OUTPUT" + else + echo "Version plus récente sur main ($NEWEST) → pas de 'latest'" + echo "value=false" >> "$GITHUB_OUTPUT" + fi - name: Set up QEMU (émulation arm64) - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - name: Login GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -35,16 +80,16 @@ jobs: - name: Métadonnées (tags + labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ghcr.io/abrahamop/goacore tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest + type=raw,value=latest,enable=${{ steps.latest.outputs.value == 'true' }} - name: Build & push (amd64 + arm64) - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c52d093..d14eee9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,15 +112,28 @@ npx tailwindcss -i ./tailwind.input.css -o ./assets/ --watch ## Lancer les tests Le projet est testé via le toolchain Go standard. Avant toute Pull Request, -assurez-vous que les trois commandes suivantes passent — ce sont celles qu'exécute -la CI : +assurez-vous que les commandes suivantes passent — ce sont celles qu'exécute la +CI, et elles y sont bloquantes : ```bash -gofmt -l . # ne doit rien afficher (sinon : gofmt -w ) -go vet ./... # analyse statique -go test ./... # suite de tests +gofmt -l . # ne doit rien afficher (sinon : gofmt -w ) +go vet ./... # analyse statique +go test ./... # suite de tests +go test ./... -race # détecteur de data races (workers, scheduler, caches) +go build ./... # compilation ``` +La CI lance en plus [`govulncheck`](https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck), +qui signale les vulnérabilités connues **réellement appelées** par le code : + +```bash +go run golang.org/x/vuln/cmd/govulncheck@latest ./... +``` + +> Cette étape est temporairement non bloquante, le temps de résorber la dette de +> dépendances existante. Ne l'aggravez pas : une PR ne doit pas introduire de +> nouvelle vulnérabilité appelée. + Les tests couvrent notamment la configuration, les middlewares (rate-limit, onboarding gate), les workers et le moteur de sauvegarde/restauration. Ajoutez des tests pour tout nouveau comportement, et veillez à ne pas casser le @@ -175,15 +188,20 @@ docs(license): AGPL-3.0 + README finalisé 1. **Forkez** le dépôt (ou créez une branche si vous avez les droits). 2. Branchez-vous depuis `dev` : `git switch dev && git switch -c feat/ma-feature`. 3. Codez, en ajoutant/maintenant les tests. -4. Vérifiez localement : `gofmt -l . && go vet ./... && go test ./...`. +4. Vérifiez localement : `gofmt -l . && go vet ./... && go test ./... -race`. 5. Committez en Conventional Commits. 6. Poussez et **ouvrez la PR vers `dev`** (ou `main` si justifié). 7. Dans la description, expliquez **le pourquoi** du changement, ce que vous avez testé, et tout impact sur la configuration (nouvelle variable d'env, migration de schéma `schema.sql`, etc.). -La CI (`go vet`, `go test`, build, validation du helper `goabackup`) doit être -verte avant relecture. Gardez les PR ciblées et de taille raisonnable : une PR = -un sujet. +La CI (`gofmt`, `go vet`, `go test` avec et sans `-race`, build, `govulncheck`, +validation du helper `goabackup`) doit être verte avant relecture. Gardez les PR +ciblées et de taille raisonnable : une PR = un sujet. + +> Les jobs déclenchés par une Pull Request s'exécutent sur un runner GitHub +> hébergé et jetable, sans accès aux secrets du dépôt : c'est normal qu'une PR +> issue d'un fork n'obtienne ni notification Discord ni déploiement. Seuls les +> `push` sur `dev`/`main`, après relecture et merge, déclenchent le déploiement. Merci pour votre contribution. diff --git a/Dockerfile b/Dockerfile index 205dace..3e2da72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,16 @@ +# Images de base épinglées PAR DIGEST : un tag comme `golang:alpine` ou +# `alpine:3.21` est mouvant, donc deux builds du même commit ne produisent pas le +# même binaire et une régression de base est indétectable. Le tag lisible reste en +# commentaire pour savoir ce qu'on épingle. +# +# Mise à jour du digest (à faire consciemment, jamais automatiquement) : +# docker buildx imagetools inspect alpine:3.21 --format '{{.Manifest.Digest}}' +# # Build Stage — exécuté sur l'arch NATIVE du runner, cross-compile le binaire Go vers # l'arch cible (TARGETOS/TARGETARCH injectés par buildx). Go cross-compile sans # émulation : les builds multi-arch (amd64/arm64) restent rapides. -FROM --platform=$BUILDPLATFORM golang:alpine AS builder +# golang:1.23-alpine3.21 +FROM --platform=$BUILDPLATFORM golang@sha256:4bb4be21ac98da06bc26437ee870c4973f8039f13e9a1a36971b4517632b0fc6 AS builder WORKDIR /app @@ -33,32 +42,67 @@ ARG TARGETARCH RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} go build -o goacloud ./cmd/server # Final Stage -FROM alpine:3.21 +# alpine:3.21 +FROM alpine@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d WORKDIR /app -# Ajout des certificats CA, timezone, ansible et client ssh -RUN apk --no-cache add ca-certificates tzdata ansible openssh +# ca-certificates + tzdata : appels sortants HTTPS et horodatage local. +# +# ansible : REQUIS par le module d'automatisation (services.RunPlaybook exécute +# ansible-playbook). Il faut en connaître le prix : il tire un interpréteur Python +# complet et plusieurs centaines de modules dans l'image d'une application web. +# C'est, de loin, la plus grosse surface d'attaque du conteneur — un module Ansible +# est du code Python exécuté localement avec les droits de l'app, et Python donne +# accès à tout ce que la Go stdlib n'exposait pas. Les garde-fous qui la bornent : +# - conteneur non-root, rootfs en lecture seule et toutes capacités retirées +# (voir docker-compose.yml : read_only / cap_drop / no-new-privileges) ; +# - seuls les playbooks de /app/playbooks sont exécutables (contrôle de chemin +# côté handlers), sur des cibles dont la clé d'hôte est épinglée en base. +# Si l'automatisation Ansible n'est pas utilisée, la bonne réponse est de rebâtir +# l'image sans cette dépendance, pas de la « durcir » davantage. +# +# openssh-client (et NON openssh) : Ansible n'a besoin que du client ssh. Le +# paquet complet embarquerait aussi sshd, c'est-à-dire un serveur SSH inutile dans +# l'image d'une application web. +RUN apk --no-cache add ca-certificates tzdata ansible openssh-client # Create non-root user RUN addgroup -g 1000 goacloud && adduser -D -u 1000 -G goacloud goacloud # Copie du binaire depuis le builder COPY --from=builder /app/goacloud . -COPY playbooks/ ./playbooks/ +# Les playbooks livrés vont dans un répertoire de RÉFÉRENCE, pas directement dans +# /app/playbooks : ce dernier est un volume, et un volume masque le contenu de +# l'image. L'entrypoint les recopie au démarrage (voir plus bas). +COPY playbooks/ ./playbooks-dist/ COPY ansible.cfg ./ -# Ensure app user owns the working directory -RUN chown -R goacloud:goacloud /app +# Répertoires que l'application écrit à l'exécution, créés ici pour qu'ils existent +# et appartiennent à l'app même quand un volume vide est monté dessus : +# certs/ → certificat TLS auto-signé PERSISTÉ (cf. internal/server/tls.go) +# playbooks/→ playbooks livrés (synchronisés au boot) + ceux de l'utilisateur +RUN mkdir -p /app/certs /app/playbooks && chown -R goacloud:goacloud /app && chmod 700 /app/certs + +# Entrypoint : resynchronise le catalogue de playbooks livré vers le volume, puis +# exec le binaire (voir docker-entrypoint.sh pour le détail et le compromis). +COPY docker-entrypoint.sh /usr/local/bin/goacore-entrypoint.sh +RUN chmod 555 /usr/local/bin/goacore-entrypoint.sh USER goacloud -# Exposition du port -EXPOSE 8080 +# Rootfs en lecture seule (docker-compose.yml) : tout ce qu'Ansible écrit doit +# aller dans le tmpfs /tmp, pas dans ~/.ansible. +ENV ANSIBLE_HOME=/tmp/.ansible + +# Port réellement servi : HTTPS 8443 (l'ancien EXPOSE 8080 ne correspondait à rien +# de publié). 8080 n'est que la redirection HTTP→HTTPS interne, non exposée. +EXPOSE 8443 # Health check HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO /dev/null https://localhost:8443/login --no-check-certificate || exit 1 # Commande de démarrage +ENTRYPOINT ["/usr/local/bin/goacore-entrypoint.sh"] CMD ["./goacloud"] diff --git a/ansible.cfg b/ansible.cfg index f7d1858..b4c1add 100644 --- a/ansible.cfg +++ b/ansible.cfg @@ -1,3 +1,8 @@ [defaults] interpreter_python = auto_silent -host_key_checking = False +# host_key_checking reste à sa valeur par défaut (True) : l'identité de l'hôte est +# vérifiée contre les clés déjà épinglées par GoaCore (table ssh_host_keys). Le +# known_hosts temporaire et StrictHostKeyChecking=yes sont injectés par +# RunPlaybook (internal/services/ansible.go). Ne pas remettre host_key_checking = +# False : cela redonnerait à n'importe quel hôte de la cible le droit de recevoir +# la clé privée et l'escalade sudo. diff --git a/assets/dom_xss_test.go b/assets/dom_xss_test.go new file mode 100644 index 0000000..4ed9531 --- /dev/null +++ b/assets/dom_xss_test.go @@ -0,0 +1,115 @@ +package assets + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Garde-fous contre le DOM-XSS corrigé en août 2026 : plusieurs vues +// interpolaient des champs d'API (nom d'agent Wazuh, nom de snapshot Proxmox, +// nom de VM) directement dans des chaînes HTML. Ces valeurs viennent de machines +// supervisées — une frontière de confiance distincte — donc potentiellement +// d'une machine compromise. + +// bareProperty décrit un `${objet.champ}` (éventuellement suivi d'un +// `|| valeur-par-défaut`) : un accès de propriété brut, sans échappement. +// Les ternaires, les appels de fonction et l'indexation par crochets sortent +// volontairement du filet — il s'agit d'un garde-fou, pas d'une preuve. +var bareProperty = regexp.MustCompile( + `^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+(?:\s*\|\|\s*(?:'[^']*'|"[^"]*"|\d+))?$`) + +// Expressions tolérées : valeurs calculées par la vue elle-même (compteurs, +// tables de classes CSS), jamais des données distantes. Toute nouvelle entrée +// doit être justifiée — dans le doute, échapper plutôt qu'ajouter une exception. +var safeInterpolation = map[string]bool{ + "c.text": true, // table de classes Tailwind indexée par sévérité + "c.bg": true, + "c.border": true, + "c.label": true, + "items.length": true, // entier calculé localement + "p.count": true, // entier agrégé localement + "names.length": true, // entier, et posé via innerText +} + +// templateLiterals extrait le contenu des littéraux gabarits (backticks) d'un +// fichier. C'est là que vit la construction de HTML côté client. +func templateLiterals(src string) []string { + var out []string + for i := 0; i < len(src); i++ { + if src[i] != '`' { + continue + } + var b strings.Builder + j := i + 1 + for j < len(src) && src[j] != '`' { + if src[j] == '\\' { + j += 2 + continue + } + b.WriteByte(src[j]) + j++ + } + out = append(out, b.String()) + i = j + } + return out +} + +var interpolation = regexp.MustCompile(`\$\{([^{}]*)\}`) + +func templateFiles(t *testing.T) []string { + t.Helper() + files, err := filepath.Glob("templates/*.html") + if err != nil || len(files) == 0 { + t.Fatalf("aucun template trouvé: %v", err) + } + return files +} + +// TestNoRawPropertyInterpolation échoue si une vue réinjecte une propriété +// d'objet brute dans un littéral gabarit. La forme attendue est +// escapeHtml(x.y) / esc(x.y). +func TestNoRawPropertyInterpolation(t *testing.T) { + for _, f := range templateFiles(t) { + body, err := os.ReadFile(f) + if err != nil { + t.Fatalf("lecture %s: %v", f, err) + } + for _, lit := range templateLiterals(string(body)) { + for _, m := range interpolation.FindAllStringSubmatch(lit, -1) { + expr := strings.TrimSpace(m[1]) + if !bareProperty.MatchString(expr) { + continue + } + key := strings.TrimSpace(strings.SplitN(expr, "||", 2)[0]) + if safeInterpolation[key] { + continue + } + t.Errorf("%s: interpolation non échappée ${%s} — utiliser escapeHtml()/esc()", f, expr) + } + } + } +} + +// TestNoJSONInHTMLAttribute échoue si une valeur est sérialisée dans un attribut +// délimité par des apostrophes : JSON.stringify n'échappe pas `'`, un nom +// d'agent contenant `x' onmouseover='…` sort donc de l'attribut. La bonne forme +// est element.dataset.foo = JSON.stringify(v) après création du nœud. +func TestNoJSONInHTMLAttribute(t *testing.T) { + pat := regexp.MustCompile(`=\s*'\$\{[^}]*\}'`) + for _, f := range templateFiles(t) { + body, err := os.ReadFile(f) + if err != nil { + t.Fatalf("lecture %s: %v", f, err) + } + for i, line := range strings.Split(string(body), "\n") { + if hit := pat.FindString(line); hit != "" { + t.Errorf("%s:%d: valeur interpolée dans un attribut entre apostrophes (%s) — passer par element.dataset", + f, i+1, hit) + } + } + } +} diff --git a/assets/static/search.js b/assets/static/search.js index 9c06b52..bb3223a 100644 --- a/assets/static/search.js +++ b/assets/static/search.js @@ -10,15 +10,15 @@ // Create modal HTML const modalHTML = `