Aller au contenu

Kubernetes

Deux manifestes déploient tout dans le namespace secreview : examples/kubernetes/postgres.yaml (PostgreSQL, image officielle) et examples/kubernetes/security-review.yaml (serveur Controller et serveur MCP, image publiée ghcr.io/cyr-ius/security-review:latest).

Déploiement

Le bloc qui déploie tout, avec le PostgreSQL de postgres.yaml, est dans Mise en service du serveur.

Avec un PostgreSQL existant : une base managée (Amazon RDS, Azure Database for PostgreSQL, Google Cloud SQL...) ou un opérateur (CloudNativePG...) qui contient une base secreview vide, propriété d'un rôle secreview. Remplacez <password> et <host> ; ajoutez ?sslmode=verify-full à l'URL quand le serveur propose le TLS.

kubectl create namespace secreview
kubectl -n secreview create secret generic secreview-db \
    --from-literal=SECREVIEW_DATABASE_URL='postgresql+psycopg2://secreview:<password>@<host>:5432/secreview'
kubectl -n secreview create secret generic secreview-controller \
    --from-literal=SECREVIEW_SECRET_KEY="$(openssl rand -hex 32)"
kubectl -n secreview apply -f https://raw.githubusercontent.com/cyr-ius/security-review/v11.0.0/examples/kubernetes/security-review.yaml
kubectl -n secreview rollout status deploy/secreview-controller
kubectl -n secreview rollout status deploy/secreview-mcp
kubectl -n secreview exec deploy/secreview-controller -c controller -- \
    security-review-controller user reset-password admin

Mise à niveau des manifestes appliqués avant les labels secreview

Les manifestes étiquettent désormais leurs ressources app.kubernetes.io/name: secreview (PostgreSQL : app.kubernetes.io/part-of: secreview) au lieu de security-review-mcp. Le sélecteur d'un Deployment ou d'un StatefulSet ne peut pas être modifié : supprimez-les avant d'appliquer les nouveaux manifestes. Le volume de PostgreSQL est conservé (son PersistentVolumeClaim n'est pas supprimé avec le StatefulSet).

kubectl -n secreview delete deploy/secreview-controller deploy/secreview-mcp statefulset/secreview-postgres

Contenu des manifestes

postgres.yaml contient un StatefulSet secreview-postgres (image officielle postgres:18-alpine, une instance, volume de 10 Gio, conteneur non root avec un système de fichiers en lecture seule), son Service et une NetworkPolicy qui ne laisse que les pods de Security Review atteindre le port 5432. En production, préférez une base managée ou un opérateur, qui gèrent la réplication, les sauvegardes et les mises à niveau.

security-review.yaml contient :

  • un Deployment secreview-controller (une réplique, stratégie Recreate), qui migre le schéma à son démarrage (une sonde startup lui laisse jusqu'à 10 minutes) ;
  • un Deployment secreview-mcp, qui vérifie seulement le schéma à son démarrage ;
  • une ConfigMap secreview-config partagée par les deux processus ;
  • un Service par processus, des sondes readiness et liveness sur /healthz, des conteneurs non root en système de fichiers en lecture seule, sans capacité Linux ;
  • une NetworkPolicy qui limite l'accès au serveur Controller aux pods du namespace et à l'ingress controller (adaptez son label).

Le serveur Controller lit aussi le Secret secreview-controller, créé à la main (voir Secrets), avec SECREVIEW_SECRET_KEY : sans lui, le serveur Controller tire un secret de session aléatoire à chaque démarrage, les sessions web prendraient donc fin à chaque redémarrage et ne seraient pas partagées entre réplicas (voir Connexion et sessions).

Contenu des manifestes de la version v11.0.0, appliqués par ces commandes :

# Single-instance PostgreSQL for Security Review, with the official image (postgres:18-alpine).
# For production, prefer a managed database or an operator (CloudNativePG...) that handles
# replication, backups and upgrades.
#
#   kubectl -n secreview create secret generic secreview-postgres \
#       --from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 24)"
#   kubectl -n secreview apply -f examples/kubernetes/postgres.yaml
#   kubectl -n secreview create secret generic secreview-db \
#       --from-literal=SECREVIEW_DATABASE_URL="postgresql+psycopg2://secreview:$(kubectl -n secreview \
#           get secret secreview-postgres -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)@secreview-postgres:5432/secreview"
#
# The Secret is not in this file, so that applying it never replaces the password.
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: secreview-postgres
  labels:
    app.kubernetes.io/name: postgresql
    app.kubernetes.io/part-of: secreview
spec:
  serviceName: secreview-postgres
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: postgresql
      app.kubernetes.io/part-of: secreview
  template:
    metadata:
      labels:
        app.kubernetes.io/name: postgresql
        app.kubernetes.io/part-of: secreview
    spec:
      # uid and gid of the postgres user of the Alpine image
      securityContext:
        runAsNonRoot: true
        runAsUser: 70
        runAsGroup: 70
        fsGroup: 70
        seccompProfile:
          type: RuntimeDefault
      automountServiceAccountToken: false
      containers:
        - name: postgres
          image: postgres:18-alpine
          env:
            - name: POSTGRES_DB
              value: secreview
            - name: POSTGRES_USER
              value: secreview
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: secreview-postgres
                  key: POSTGRES_PASSWORD
          ports:
            - name: postgres
              containerPort: 5432
          readinessProbe: &probe
            exec:
              command: ["pg_isready", "-U", "secreview", "-d", "secreview"]
            periodSeconds: 10
          livenessProbe: *probe
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              memory: 1Gi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            # Since PostgreSQL 18, the image keeps its data under /var/lib/postgresql/18/docker
            - name: data
              mountPath: /var/lib/postgresql
            - name: run
              mountPath: /var/run/postgresql
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: run
          emptyDir: {}
        - name: tmp
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
  name: secreview-postgres
spec:
  selector:
    app.kubernetes.io/name: postgresql
    app.kubernetes.io/part-of: secreview
  ports:
    - name: postgres
      port: 5432
      targetPort: postgres
---
# Only the pods of Security Review (controller, MCP server) reach the database
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: secreview-postgres
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: postgresql
      app.kubernetes.io/part-of: secreview
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector:
            matchLabels:
              app.kubernetes.io/name: secreview
      ports:
        - port: 5432
# Security Review on Kubernetes: the MCP server and the controller in two Deployments
# sharing a PostgreSQL database (not included: examples/kubernetes/postgres.yaml deploys the
# official image, or use your managed or operator-backed instance).
#
#   kubectl create namespace secreview
#   kubectl -n secreview create secret generic secreview-db \
#       --from-literal=SECREVIEW_DATABASE_URL='postgresql+psycopg2://secreview:<password>@<host>:5432/secreview'
#   kubectl -n secreview create secret generic secreview-controller \
#       --from-literal=SECREVIEW_SECRET_KEY="$(openssl rand -hex 32)"
#   kubectl -n secreview apply -f examples/kubernetes/security-review.yaml
#   kubectl -n secreview rollout status deploy/secreview-controller
#   kubectl -n secreview exec deploy/secreview-controller -c controller -- \
#       security-review-controller user reset-password admin   # initial admin password
#
# The published image contains the PostgreSQL driver; pin a version (X.Y.Z) in production.
#
# The controller migrates the schema when it starts (Recreate strategy: the old controller pods
# stop before the new schema is applied; concurrent migrations wait for each other on a
# PostgreSQL lock). The MCP pods only check the schema when they start: new ones restart until
# the controller has migrated it. Expose each Service through its own Ingress with TLS: the MCP
# server to the agents, the controller to the people (web interface, personal MCP tokens) and
# to the CI (security-review-ci calls the REST API of the controller).
#
# The secreview-controller Secret gives SECREVIEW_SECRET_KEY: without it, the controller draws
# a random session secret at every start (sessions end at restart and are not shared by the
# replicas). Put the proxy credentials there too. The Secrets are not in this file, so that
# applying it never replaces them; as manifests (keep them out of git, or encrypted):
#
#   apiVersion: v1
#   kind: Secret
#   metadata:
#     name: secreview-controller
#   type: Opaque
#   stringData:
#     SECREVIEW_SECRET_KEY: <openssl rand -hex 32>
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: secreview-config
data:
  SECREVIEW_LOG_LEVEL: INFO
  # Address range of the ingress controller pods: their X-Forwarded-For gives the client
  # address (login throttling, audit log, logs). Adapt it to your cluster.
  # SECREVIEW_FORWARDED_ALLOW_IPS: "10.0.0.0/8"
  # Public root URL of the controller, copied to the database by the controller at start: the
  # MCP server gives its web interface (/ui) to the clients without a valid token (enrollment,
  # 401) so that the user knows where to create a personal MCP token. Unset: set it in the web
  # interface (General).
  # SECREVIEW_PUBLIC_URL: https://secreview.example.com
  # OAuth login of the MCP clients (read by the controller, which needs the URL above in https):
  # the hosts sign in through the browser instead of a personal MCP token. Publish the OAuth
  # paths: see the Ingress at the end of this file.
  # SECREVIEW_MCP_OAUTH_ENABLED: "true"
  # SECREVIEW_MCP_URL: https://mcp.secreview.example.com/mcp
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secreview-controller
  labels:
    app.kubernetes.io/name: secreview
    app.kubernetes.io/component: controller
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: secreview
      app.kubernetes.io/component: controller
  template:
    metadata:
      labels:
        app.kubernetes.io/name: secreview
        app.kubernetes.io/component: controller
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      automountServiceAccountToken: false
      containers:
        - name: controller
          image: ghcr.io/cyr-ius/security-review:latest
          # The image listens on every interface of the pod, port 8000
          # The image starts the Controller server with the `controller` command
          args: ["controller"]
          envFrom:
            - configMapRef:
                name: secreview-config
            - secretRef:
                name: secreview-db
            # SECREVIEW_SECRET_KEY (and the proxy variables when they hold credentials)
            - secretRef:
                name: secreview-controller
          env:
            # Swagger documentation of the REST API on /api/docs (disabled by default)
            # - name: SECREVIEW_SWAGGER_ENABLED
            #   value: "true"
            # Daily download of the OSV advisories (needs egress to
            # osv-vulnerabilities.storage.googleapis.com or a mirror);
            # put credentials of the proxy in the secreview-controller Secret rather than here
            # - name: SECREVIEW_OSV_AUTO_UPDATE
            #   value: "true"
            # - name: SECREVIEW_OSV_ECOSYSTEMS
            #   value: PyPI,npm,Maven
            # Proxy of the OSV download and of the OIDC provider (standard variables)
            # - name: HTTPS_PROXY
            #   value: http://proxy.example.com:3128
            # - name: HTTP_PROXY  # fallback when HTTPS_PROXY is unset
            #   value: http://proxy.example.com:3128
            # - name: NO_PROXY
            #   value: .svc,.cluster.local
          ports:
            - name: http
              containerPort: 8000
          readinessProbe: &probe
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
          livenessProbe: *probe
          # The controller migrates the schema before it listens: up to 10 minutes before the
          # liveness probe may restart it
          startupProbe:
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
            failureThreshold: 60
          resources:
            requests:
              cpu: 50m
              memory: 128Mi
            limits:
              memory: 512Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: secreview-controller
spec:
  selector:
    app.kubernetes.io/name: secreview
    app.kubernetes.io/component: controller
  ports:
    - name: http
      port: 80
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: secreview-mcp
  labels:
    app.kubernetes.io/name: secreview
    app.kubernetes.io/component: mcp
spec:
  # MCP sessions live in the memory of a pod: before adding replicas, make the Ingress route
  # each session to the same pod (affinity on the Mcp-Session-Id header)
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: secreview
      app.kubernetes.io/component: mcp
  template:
    metadata:
      labels:
        app.kubernetes.io/name: secreview
        app.kubernetes.io/component: mcp
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
        runAsGroup: 10001
        seccompProfile:
          type: RuntimeDefault
      automountServiceAccountToken: false
      containers:
        - name: mcp
          image: ghcr.io/cyr-ius/security-review:latest
          # The image starts the MCP server with the `mcp` command: streamable HTTP
          # (SECREVIEW_TRANSPORT) on every interface of the pod, port 8000
          args: ["mcp"]
          envFrom:
            - configMapRef:
                name: secreview-config
            - secretRef:
                name: secreview-db
          ports:
            - name: http
              containerPort: 8000
          readinessProbe: &probe
            httpGet:
              path: /healthz
              port: http
            periodSeconds: 10
          livenessProbe: *probe
          resources:
            requests:
              cpu: 50m
              memory: 128Mi
            limits:
              memory: 512Mi
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: secreview-mcp
spec:
  selector:
    app.kubernetes.io/name: secreview
    app.kubernetes.io/component: mcp
  ports:
    - name: http
      port: 80
      targetPort: http
---
# Only the pods of the namespace and the ingress controller (adjust its label) reach the
# controller: people and the CI go through its Ingress. The MCP server stays reachable for the
# agents through its own Ingress.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: secreview-controller
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: secreview
      app.kubernetes.io/component: controller
  policyTypes: ["Ingress"]
  ingress:
    - from:
        - podSelector: {}
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress-nginx
      ports:
        - port: 8000
---
# Ingress (ingress-nginx, TLS by cert-manager: adapt the class, the issuer and the hosts). One
# host per service: every path of a host goes to its service, so the OAuth paths reach the right
# process with no extra rule (protected resource metadata on the MCP host, authorization server
# on the controller host). Uncomment to use it.
#
# apiVersion: networking.k8s.io/v1
# kind: Ingress
# metadata:
#   name: secreview
#   annotations:
#     cert-manager.io/cluster-issuer: letsencrypt
#     # Long MCP streams (server-sent events) and large scans of security-review-ci
#     nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
#     nginx.ingress.kubernetes.io/proxy-body-size: 64m
# spec:
#   ingressClassName: nginx
#   tls:
#     - hosts: [secreview.example.com, mcp.secreview.example.com]
#       secretName: secreview-tls
#   rules:
#     - host: secreview.example.com          # controller: web interface, REST API, OAuth
#       http:
#         paths:
#           - path: /
#             pathType: Prefix
#             backend: {service: {name: secreview-controller, port: {name: http}}}
#     - host: mcp.secreview.example.com      # MCP server: /mcp and its OAuth metadata
#       http:
#         paths:
#           - path: /
#             pathType: Prefix
#             backend: {service: {name: secreview-mcp, port: {name: http}}}
#
# With a single host for both, route by path instead: /mcp and
# /.well-known/oauth-protected-resource to secreview-mcp; everything else to
# secreview-controller (/ui, /api, /.well-known/oauth-authorization-server, /authorize,
# /token, /register, /revoke).

Secrets

Les Secrets (secreview-postgres, secreview-db, secreview-controller) ne sont pas dans les exemples, pour que kubectl apply ne remplace jamais leurs valeurs. Le mot de passe de secreview-postgres et celui de l'URL de secreview-db doivent être identiques. Pour les déclarer en manifestes plutôt qu'avec kubectl create secret (GitOps), utilisez kind: Secret :

apiVersion: v1
kind: Secret
metadata:
  name: secreview-postgres
  namespace: secreview
type: Opaque
stringData:
  POSTGRES_PASSWORD: <password>
---
apiVersion: v1
kind: Secret
metadata:
  name: secreview-db
  namespace: secreview
type: Opaque
stringData:
  SECREVIEW_DATABASE_URL: postgresql+psycopg2://secreview:<password>@secreview-postgres:5432/secreview
---
apiVersion: v1
kind: Secret
metadata:
  name: secreview-controller
  namespace: secreview
type: Opaque
stringData:
  # openssl rand -hex 32 (32 caractères au moins, inchangé d'un déploiement à l'autre)
  SECREVIEW_SECRET_KEY: <64 caractères hexadécimaux>
  # Proxy avec identifiants (téléchargement OSV, OIDC), facultatif
  # HTTPS_PROXY: http://user:<password>@proxy.example.com:3128

stringData reçoit les valeurs en clair ; Kubernetes les stocke encodées en base64 dans data. Ne commitez jamais ce fichier avec de vraies valeurs : chiffrez-le (Sealed Secrets, SOPS) ou laissez un opérateur comme External Secrets le créer depuis votre coffre. Les pods lisent les Secrets au démarrage : après un changement, redémarrez-les (kubectl -n secreview rollout restart deploy/secreview-controller). Changer SECREVIEW_SECRET_KEY termine toutes les sessions web.

Image, exposition et administration

Le manifeste utilise l'image publiée ghcr.io/cyr-ius/security-review:latest, qui contient le pilote PostgreSQL : épinglez une version (X.Y.Z) en production. Exposez chaque Service par son propre Ingress avec TLS : un exemple, commenté, termine security-review.yaml (un hôte par service, ingress-nginx et cert-manager).

Service Atteint par Ingress
secreview-mcp les agents (MCP sur /mcp, et /.well-known/oauth-protected-resource avec la connexion OAuth) vers tous les postes des développeurs
secreview-controller les personnes (interface web /ui, jetons MCP et CI personnels), la CI (security-review-ci appelle /api/v1/ci) et, avec la connexion OAuth, les hôtes MCP (/.well-known/oauth-authorization-server, /authorize, /token, /register, /revoke) vers les développeurs et les runners de CI ; kubectl port-forward suffit pour un premier essai

Avec un hôte par Service, chaque chemin va à son Service : les chemins OAuth n'ont besoin d'aucune règle propre. Avec un seul hôte pour les deux, routez /mcp et /.well-known/oauth-protected-resource vers secreview-mcp, tout le reste vers secreview-controller.

Ensuite, dans la ConfigMap :

  • réglez SECREVIEW_PUBLIC_URL sur l'URL publique racine du serveur Controller (https://…), ou renseignez la page Général : le serveur MCP donne son interface web (/ui) aux clients sans jeton valide, pour que l'utilisateur sache où créer un jeton MCP personnel ;
  • pour que les hôtes MCP se connectent par le navigateur plutôt qu'avec un jeton MCP personnel, réglez SECREVIEW_MCP_OAUTH_ENABLED et SECREVIEW_MCP_URL (URL publique de /mcp), avec SECREVIEW_PUBLIC_URL en https (voir Connexion OAuth des clients MCP) ;
  • réglez SECREVIEW_FORWARDED_ALLOW_IPS sur la plage d'adresses des pods de l'ingress controller : sinon tous les clients partagent leur adresse pour la limitation des connexions, la limite de débit et le journal d'audit.

La NetworkPolicy du serveur Controller laisse entrer l'ingress controller du namespace ingress-nginx : adaptez son label au vôtre. Les commandes d'administration s'exécutent dans le pod du serveur Controller :

kubectl -n secreview exec deploy/secreview-controller -c controller -- \
    security-review-controller token list

Mise à jour OSV

Pour la mise à jour quotidienne des avis OSV, décommentez SECREVIEW_OSV_AUTO_UPDATE (et au besoin SECREVIEW_OSV_ECOSYSTEMS, HTTPS_PROXY et NO_PROXY, dans le Secret secreview-controller si le proxy contient des identifiants) sur le conteneur controller, et autorisez sa sortie vers osv-vulnerabilities.storage.googleapis.com (SECREVIEW_OSV_URL), votre miroir ou le proxy si une NetworkPolicy d'egress s'applique. Le serveur MCP n'a besoin d'aucune sortie. La documentation Swagger de l'API REST (/api/docs) reste désactivée tant que vous ne décommentez pas SECREVIEW_SWAGGER_ENABLED.

Plusieurs réplicas

Plusieurs réplicas du serveur MCP

Les sessions MCP (transport streamable HTTP) sont gardées en mémoire par le pod qui les a ouvertes. Avant d'augmenter replicas, configurez l'Ingress pour envoyer toutes les requêtes d'une session au même pod (affinité sur l'en-tête Mcp-Session-Id, ou à défaut affinité par cookie ou par adresse). Le serveur Controller peut, lui, avoir plusieurs réplicas : leurs migrations s'attendent (verrou consultatif PostgreSQL), et SECREVIEW_SECRET_KEY leur permet de partager les sessions web.