diff --git a/.gitea/workflows/cd-main.yml b/.gitea/workflows/cd-main.yml index 8f8bca4..65811a6 100644 --- a/.gitea/workflows/cd-main.yml +++ b/.gitea/workflows/cd-main.yml @@ -231,8 +231,11 @@ jobs: PLATFORM: linux/${{ matrix.arch }} run: | trivy image --platform "$PLATFORM" --scanners vuln --severity HIGH,CRITICAL \ - --ignore-unfixed=false --exit-code 1 --timeout 15m --format json \ + --ignore-unfixed=false --exit-code 1 --timeout 15m --no-progress --format json \ --output "$RUNNER_TEMP/image-security.json" "$IMAGE" + - name: Show image scan results + if: always() + run: python3 scripts/ci/summarize-image-security.py - name: Save image report if: always() uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3, Gitea artifact protocol diff --git a/.gitea/workflows/cd-preprod.yml b/.gitea/workflows/cd-preprod.yml index 6c78a22..3a9fb15 100644 --- a/.gitea/workflows/cd-preprod.yml +++ b/.gitea/workflows/cd-preprod.yml @@ -312,8 +312,11 @@ jobs: PLATFORM: linux/${{ matrix.arch }} run: | trivy image --platform "$PLATFORM" --scanners vuln --severity HIGH,CRITICAL \ - --ignore-unfixed=false --exit-code 1 --timeout 15m --format json \ + --ignore-unfixed=false --exit-code 1 --timeout 15m --no-progress --format json \ --output "$RUNNER_TEMP/image-security.json" "$IMAGE" + - name: Show image scan results + if: always() + run: python3 scripts/ci/summarize-image-security.py - name: Save image report if: always() uses: https://github.com/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3, Gitea artifact protocol diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index 38c03aa..c6a65e5 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -40,6 +40,10 @@ RUN npm prune --production --legacy-peer-deps # =============================================== FROM node:22-alpine AS production +# Runtime starts with node; remove the base image package manager and its dependencies. +# npm remains available in the dependency and build stages. +RUN npm uninstall --global npm + # Install dumb-init for proper signal handling RUN apk add --no-cache dumb-init diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile index 0b5ae20..30cfa03 100644 --- a/apps/frontend/Dockerfile +++ b/apps/frontend/Dockerfile @@ -50,6 +50,10 @@ RUN npm run build # =============================================== FROM node:22-alpine AS production +# Runtime starts with node; remove the base image package manager and its dependencies. +# npm remains available in the dependency and build stages. +RUN npm uninstall --global npm + # Install dumb-init for proper signal handling RUN apk add --no-cache dumb-init curl diff --git a/apps/log-exporter/Dockerfile b/apps/log-exporter/Dockerfile index f10efc1..83d42ce 100644 --- a/apps/log-exporter/Dockerfile +++ b/apps/log-exporter/Dockerfile @@ -5,6 +5,9 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev +# Only Node and application dependencies are needed at runtime. +RUN npm uninstall --global npm + COPY src/ ./src/ EXPOSE 3200 diff --git a/docs/CI-CD-SECURITY.md b/docs/CI-CD-SECURITY.md index 132378a..378bd86 100644 --- a/docs/CI-CD-SECURITY.md +++ b/docs/CI-CD-SECURITY.md @@ -234,3 +234,34 @@ exécution Gitea n'est effectué par cette correction locale. Sources : [migration Next 15](https://nextjs.org/docs/app/guides/upgrading/version-15), [distribution SheetJS](https://docs.sheetjs.com/docs/getting-started/installation/nodejs/), [exceptions Trivy](https://trivy.dev/docs/dev/configuration/filtering/). + +## Images de déploiement : npm global (25 septembre 2026) + +Les rapports du run Gitea 150 attribuent huit alertes HIGH identiques, sur AMD64 +et ARM64, aux dépendances du npm global livré dans `node:22-alpine` : +`usr/local/lib/node_modules/npm/node_modules/...`. Les audits npm du projet ne +couvrent pas ce gestionnaire global ; un audit applicatif vert ne suffit donc +pas à valider l'image finale. + +Les trois Dockerfiles désinstallent le npm global avec `npm uninstall --global +npm` uniquement après les installations nécessaires, dans l'image d'exécution. +Le backend et le frontend gardent npm dans leurs étages de construction. +Les services démarrent avec Node, et les migrations backend utilisent directement +TypeORM via `startup.js`. La procédure manuelle Portainer utilise également Node. +Aucune CVE n'est ignorée et les scans par digest restent bloquants en préprod/prod. + +Chaque job de scan affiche maintenant un résumé indiquant CVE, paquet, version, +correctif disponible et chemin, même après échec. Le rapport JSON complet reste +joint en artefact. Les barres de progression sont désactivées pour éviter les +journaux illisibles. L'avertissement de Trivy 0.74 sur la liste EOL d'Alpine 3.24 +n'est pas la cause du code de sortie 1 observé : il est aussi présent sur les +scans corrigés qui retournent 0. + +Validation du correctif : reconstruction des trois images ARM64 et du log-exporter +AMD64 ; scans Trivy au seuil HIGH/CRITICAL sans exclusions ; HTTP 200 sur les +routes de santé frontend et log-exporter ; chargement du démarrage backend et de +la CLI TypeORM sans npm/npx ; validation des workflows et 30 tests de scripts. +Les images backend/frontend AMD64 et le déploiement restent à confirmer par la +prochaine exécution Gitea après publication du correctif. Relancer uniquement les +anciens jobs de scan réanalyse les anciens digests vulnérables : il faut rebâtir +les images à partir du commit corrigé. diff --git a/docs/deployment/portainer.md b/docs/deployment/portainer.md index 4d038cb..82a1955 100644 --- a/docs/deployment/portainer.md +++ b/docs/deployment/portainer.md @@ -144,12 +144,12 @@ NEXT_PUBLIC_API_URL=https://api.xpeditis.com ## Migrations automatiques -Le backend exécute les migrations automatiquement au démarrage via le script `docker-entrypoint.sh` : +Le backend exécute les migrations automatiquement via `startup.js`, qui attend PostgreSQL, +appelle directement TypeORM puis lance le serveur Node. npm n’est pas présent dans l’image finale : ```bash -# apps/backend/docker-entrypoint.sh attend PostgreSQL puis : -npm run migration:run -node dist/main.js +# Commande de démarrage de l’image +node startup.js ``` Les logs Portainer affichent : @@ -199,8 +199,7 @@ traefik.http.services.frontend.loadbalancer.server.port=3000 ### Migrations échouent ```bash -docker exec -it xpeditis-backend sh -cd /app && npm run migration:run +docker exec -it xpeditis-backend node -e 'require("./startup").runMigrations().catch(() => process.exit(1))' ``` Si blocage : vérifier que PostgreSQL est accessible (`DATABASE_HOST` = nom du service Docker). diff --git a/scripts/ci/summarize-image-security.py b/scripts/ci/summarize-image-security.py new file mode 100644 index 0000000..3080eed --- /dev/null +++ b/scripts/ci/summarize-image-security.py @@ -0,0 +1,32 @@ +"""Show actionable image findings even when the blocking Trivy step failed.""" +import json +import os +from pathlib import Path + + +def summarize(path): + try: + report = json.loads(path.read_text()) + if not isinstance(report, dict) or not isinstance(report.get('Results'), list): + raise ValueError('Missing scan results') + except (OSError, ValueError): + print('Image report missing or invalid: inspect the Trivy step; scan not verified.') + return 1 + + count = 0 + for result in report['Results']: + for finding in result.get('Vulnerabilities') or []: + if finding.get('Severity') not in ('HIGH', 'CRITICAL'): + continue + count += 1 + print(f'{finding["Severity"]} {finding.get("VulnerabilityID", "unknown")}: ' + f'{finding.get("PkgName", "unknown")} ' + f'{finding.get("InstalledVersion", "unknown")} -> ' + f'{finding.get("FixedVersion") or "no fixed version published"}') + print(f' Path: {finding.get("PkgPath") or result.get("Target", "unknown")}') + print(f'{count} HIGH/CRITICAL image findings. Full details: image-security artifact.') + return int(count > 0) + + +if __name__ == '__main__': + raise SystemExit(summarize(Path(os.environ['RUNNER_TEMP']) / 'image-security.json')) diff --git a/scripts/ci/test_image_summary.py b/scripts/ci/test_image_summary.py new file mode 100644 index 0000000..c497b20 --- /dev/null +++ b/scripts/ci/test_image_summary.py @@ -0,0 +1,40 @@ +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import tempfile +import unittest + +spec = importlib.util.spec_from_file_location( + 'image_summary', Path(__file__).with_name('summarize-image-security.py')) +summary = importlib.util.module_from_spec(spec) +spec.loader.exec_module(summary) + + +class ImageSummary(unittest.TestCase): + def test_reports_global_npm_path_and_preserves_failure(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'image-security.json' + path.write_text(json.dumps({'Results': [{'Vulnerabilities': [{ + 'Severity': 'HIGH', 'VulnerabilityID': 'CVE-example', + 'PkgName': 'pacote', 'InstalledVersion': '19.0.2', + 'FixedVersion': '21.5.1', + 'PkgPath': 'usr/local/lib/node_modules/npm/node_modules/pacote/package.json', + }]}]})) + output = io.StringIO() + with contextlib.redirect_stdout(output): + self.assertEqual(summary.summarize(path), 1) + self.assertIn('19.0.2 -> 21.5.1', output.getvalue()) + self.assertIn('usr/local/lib/node_modules/npm/', output.getvalue()) + + def test_missing_invalid_and_clean_reports(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'image-security.json' + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(summary.summarize(path), 1) + for content in ('broken', '{}', '{"Results": null}'): + path.write_text(content) + self.assertEqual(summary.summarize(path), 1) + path.write_text('{"Results": []}') + self.assertEqual(summary.summarize(path), 0)