Quantum-Safe Cloud -- Parte 5

CI/CD Quantum-Safe: Se Acabaron los Secretos en los Pipelines

#security #post-quantum #cicd #azure-devops #quantumvault #kubernetes #supply-chain

El Problema

El pipeline de la serie ATLAS+GOTCHA es un buen punto de partida. Hace build, tests, escanea con Trivy y despliega con rolling updates sin downtime.

Pero tiene tres brechas de seguridad que no abordamos en aquella serie.

Brecha 1: Los variable groups no son quantum-safe. Los variable groups de Azure DevOps con respaldo de Key Vault están cifrados en reposo con claves gestionadas por Azure — RSA por defecto. Mejor que texto plano en YAML. No es seguro contra harvest-now-decrypt-later. Y los secretos en sí transitan por la memoria del agente de Azure DevOps en texto plano.

Brecha 2: Las imágenes no están firmadas. Trivy escanea la imagen buscando CVEs antes de hacer push. Pero una vez la imagen está en ACR, cualquiera con acceso de escritura puede subir una nueva imagen con el mismo tag. No hay nada que impida que una imagen maliciosa reemplace una limpia. El deployment de Kubernetes no tiene forma de verificar que lo que está descargando es lo que el pipeline subió.

Brecha 3: Cadena de suministro. El escaneo de Trivy detecta paquetes vulnerables. Pero no verifica que la imagen base no haya sido manipulada, que el build agent no haya sido comprometido, o que los paquetes de NuGet sean los que esperas. Estos son vectores de ataque a la cadena de suministro. SolarWinds, Log4Shell, XZ Utils — todos explotaron esta brecha.

Este artículo soluciona los tres: QuantumVault para secretos del pipeline, Cosign para firma de imágenes y generación de SBOM para transparencia en la cadena de suministro.

La Solución

Tres adiciones al pipeline existente:

  1. Resolución de secretos con QuantumVault: el pipeline obtiene los secretos de QuantumVault en tiempo de ejecución a través del CLI qapi. Cero secretos almacenados en Azure DevOps.

  2. Firma de imágenes con Cosign: después de hacer push a ACR, el pipeline firma la imagen con una clave ML-DSA almacenada en QuantumVault. El admission control de Kubernetes verifica la firma antes de ejecutar cualquier pod.

  3. Generación de SBOM: dotnet publish genera un SBOM en formato CycloneDX. El SBOM se adjunta a la imagen del contenedor y se sube a ACR junto con la imagen. Trazabilidad completa para cada dependencia en producción.

Execute

ATLAS para esta tarea

[A] ARCHITECT
  Extend the existing Azure DevOps pipeline (users-api).
  Remove all secrets from variable groups.
  Sign every image pushed to ACR.
  Generate and attach SBOM to every image.
  Out of scope: Kubernetes admission webhook (Gatekeeper/Ratify) — article 6.

[T] TRACE
  Pipeline starts → qapi CLI authenticates to QuantumVault using a service account API key
  → Fetches DB_CONNECTION, JWT_SECRET (now from QuantumVault, not variable group)
  → Build + test (same as before)
  → docker build → Trivy scan → docker push to ACR (same as before)
  → Cosign: fetch ML-DSA signing key from QuantumVault → sign the image in ACR
  → SBOM: generate CycloneDX → attach to image in ACR
  → Deploy: kubectl rolling update (same as before)

[L] LINK
  ADO pipeline → QuantumVault: HTTPS, service account API key (stored in ADO variable group
    as QUANTUMAPI_KEY — this is the only secret remaining in ADO)
  ADO pipeline → ACR: service connection (unchanged)
  Cosign → ACR: OCI artifact push
  Cosign → QuantumVault: fetch signing key material

[A] ASSEMBLE
  Phase 1: Install qapi CLI on the build agent
  Phase 2: Replace variable group secret reads with qapi vault get calls
  Phase 3: Add Cosign signing stage after image push
  Phase 4: Add SBOM generation stage

[S] STRESS-TEST
  QuantumVault unreachable during pipeline?
    → qapi returns exit code 1 → pipeline fails fast before build
  Image signing fails?
    → Don't deploy an unsigned image — fail the pipeline
  SBOM generation fails?
    → Warn, but don't fail — SBOM is audit trail, not a gate

Paso 1: Instalar el CLI qapi

Añade un step de instalación al principio del pipeline:

# At the top of your existing azure-pipelines.yml

variables:
  - name: QAPI_VERSION
    value: "1.2.0"

stages:
  # ─────────────────────────────────────────
  - stage: setup
    displayName: Setup
    jobs:
      - job: install_tools
        displayName: Install qapi CLI
        pool:
          vmImage: ubuntu-latest
        steps:
          - script: |
              curl -sfL https://cli.quantumapi.eu/install.sh \
                | sh -s -- -v $(QAPI_VERSION) -b /usr/local/bin
              qapi version
            displayName: Install qapi
            env:
              QUANTUMAPI_KEY: $(QUANTUMAPI_KEY) # ← only ADO secret remaining

El QUANTUMAPI_KEY es la API key de la service account para tu pipeline. Este es el único secreto que se queda en los variable groups de Azure DevOps. Todo lo demás se obtiene de QuantumVault.

Paso 2: Reemplazar los secretos del variable group con QuantumVault

Antes (pipeline del artículo 6):

variables:
  - group: users-api-secrets  # DB_CONNECTION, JWT_SECRET here

Después (pipeline quantum-safe):

variables:
  - name: QUANTUMAPI_KEY      # Only this remains — the bootstrap key
    value: $(QUANTUMAPI_KEY)  # From ADO variable group, not Key Vault
  - name: DB_SECRET_ID
    value: "7c3a1f9d-4e2b-8a6c-0f5d-3b9e1c7a4f2d"  # Just an ID, not a secret
  - name: JWT_SECRET_ID
    value: "2a8f4c1e-9b3d-7f5a-1e4c-6d0b8e3f2a9c"

En el stage de build, obtenemos los secretos en tiempo de ejecución:

- stage: build
  displayName: Build & Test
  jobs:
    - job: build
      pool:
        vmImage: ubuntu-latest
      steps:
        - script: |
            # Fetch secrets from QuantumVault at runtime
            DB_CONNECTION=$(qapi vault get $(DB_SECRET_ID))
            JWT_SECRET=$(qapi vault get $(JWT_SECRET_ID))
            # Export as pipeline variables (masked in logs automatically)
            echo "##vso[task.setvariable variable=DB_CONNECTION;issecret=true]$DB_CONNECTION"
            echo "##vso[task.setvariable variable=JWT_SECRET;issecret=true]$JWT_SECRET"
          displayName: Fetch secrets from QuantumVault
          env:
            QUANTUMAPI_KEY: $(QUANTUMAPI_KEY)

        - task: UseDotNet@2
          inputs:
            version: "10.x"

        - script: dotnet restore
          displayName: Restore

        - script: dotnet build --no-restore --configuration Release
          displayName: Build
          env:
            ConnectionStrings__DefaultConnection: $(DB_CONNECTION)
            JwtSettings__Secret: $(JWT_SECRET)

        - script: |
            dotnet test --no-build --configuration Release \
              --logger "junit;LogFilePath=$(Agent.TempDirectory)/test-results.xml"
          displayName: Test
          env:
            ConnectionStrings__DefaultConnection: $(DB_CONNECTION)

        - task: PublishTestResults@2
          condition: always()
          inputs:
            testResultsFormat: JUnit
            testResultsFiles: "$(Agent.TempDirectory)/test-results.xml"

        - script: dotnet format --verify-no-changes
          displayName: Format check

Los secretos se obtienen en variables efímeras del pipeline marcadas como issecret=true — Azure DevOps las enmascara en toda la salida de logs. Solo existen durante la ejecución del job del pipeline.

Paso 3: Generar el SBOM

Añade la generación del SBOM al stage de build, después de que el build sea exitoso:

        - script: |
            dotnet tool install --global CycloneDX
            dotnet CycloneDX . \
              --output $(Build.ArtifactStagingDirectory)/sbom \
              --json \
              --filename sbom.json
          displayName: Generate SBOM

        - task: PublishBuildArtifacts@1
          inputs:
            PathtoPublish: "$(Build.ArtifactStagingDirectory)/sbom"
            ArtifactName: sbom

Esto genera un SBOM en formato CycloneDX JSON que lista cada dependencia de NuGet con su versión y CVEs conocidos. El SBOM se publica como artefacto del build y también se adjunta a la imagen del contenedor en el siguiente stage.

Paso 4: Firmar la imagen con Cosign

Añade un stage de firma después del push de la imagen:

  # ─────────────────────────────────────────
  - stage: sign
    displayName: Sign & Attest
    dependsOn: image
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - job: sign_image
        displayName: Cosign + SBOM attestation
        pool:
          vmImage: ubuntu-latest
        steps:
          - script: |
              # Install Cosign
              curl -sfL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \
                -o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign

              # Fetch ML-DSA signing key from QuantumVault
              qapi vault get $(SIGNING_KEY_SECRET_ID) \
                --format pem > /tmp/signing-key.pem

              # Sign the image in ACR
              cosign sign \
                --key /tmp/signing-key.pem \
                --registry-username $(acrUsername) \
                --registry-password $(acrPassword) \
                $(acrName).azurecr.io/users-api:$(IMAGE_TAG)

              # Attach SBOM as attestation
              cosign attest \
                --key /tmp/signing-key.pem \
                --type cyclonedx \
                --predicate sbom/sbom.json \
                $(acrName).azurecr.io/users-api:$(IMAGE_TAG)

              # Clean up key from disk
              rm -f /tmp/signing-key.pem
            displayName: Sign image and attach SBOM
            env:
              QUANTUMAPI_KEY: $(QUANTUMAPI_KEY)
              COSIGN_EXPERIMENTAL: "1"

Paso 5: Verificar la firma antes de desplegar

En el stage de deploy, verificamos la firma antes de ejecutar kubectl:

  - stage: deploy
    displayName: Deploy to AKS
    dependsOn: sign
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: deploy_aks
        pool:
          vmImage: ubuntu-latest
        environment: production
        strategy:
          runOnce:
            deploy:
              steps:
                - script: |
                    # Fetch public key for verification
                    qapi keys get $(SIGNING_KEY_ID) --public-only > /tmp/signing-pub.pem

                    # Verify signature before deploying
                    cosign verify \
                      --key /tmp/signing-pub.pem \
                      $(acrName).azurecr.io/users-api:$(IMAGE_TAG) \
                    || (echo "Image signature verification failed" && exit 1)
                  displayName: Verify image signature
                  env:
                    QUANTUMAPI_KEY: $(QUANTUMAPI_KEY)

                - task: KubernetesManifest@1
                  displayName: Deploy to AKS
                  inputs:
                    action: patch
                    resourceToPatch: deployment/users-api
                    namespace: users-api
                    patch: |
                      spec:
                        template:
                          spec:
                            containers:
                            - name: users-api
                              image: $(acrName).azurecr.io/users-api:$(IMAGE_TAG)

Cómo queda el flujo completo de secretos

Azure DevOps variable group (before)
├── DB_CONNECTION = "Server=..."       ← RSA-encrypted in Azure KV
├── JWT_SECRET = "super-secret"       ← RSA-encrypted in Azure KV
└── ACR_NAME = "myacr"               ← not a secret

Azure DevOps variable group (after)
├── QUANTUMAPI_KEY = "qid_..."        ← only this remains, still ADO-managed
├── DB_SECRET_ID = "7c3a1f9d-..."     ← just an ID, not a secret
├── JWT_SECRET_ID = "2a8f4c1e-..."    ← just an ID, not a secret
├── SIGNING_KEY_SECRET_ID = "..."     ← ID of the PEM in QuantumVault
├── SIGNING_KEY_ID = "..."            ← ID of the key pair (for public key fetch)
└── ACR_NAME = "myacr"               ← not a secret

QuantumVault (source of truth)
├── users-api/db-connection           ← ML-KEM encrypted at rest
├── users-api/jwt-secret              ← ML-KEM encrypted at rest
└── users-api/cosign-signing-key      ← ML-KEM encrypted at rest

Lo que ajustamos

1. Limpieza de la clave. La IA dejó el fichero de la signing key en disco entre steps. Añadimos rm -f /tmp/signing-key.pem justo después de que Cosign termine. En un agent pool compartido, esto importa.

2. SBOM como aviso, no como bloqueo. La IA puso la generación del SBOM antes de Trivy y la hizo un gate obligatorio. Lo movimos después del build y lo dejamos como advisory — un warning en los logs si falla, no un fallo del pipeline. La razón: la generación del SBOM es útil pero a veces falla con dependencias transitivas complejas. No dejes que bloquee tus despliegues.

3. issecret=true en las variables obtenidas. La IA usó echo "##vso[task.setvariable variable=DB_CONNECTION]$DB_CONNECTION" — sin issecret=true. Esto habría mostrado la connection string en los logs. Siempre marca los secretos obtenidos como secret.

Template

=== QUANTUM-SAFE CI/CD CHECKLIST ===

SECRETS
[ ] Identify all secrets currently in variable groups
[ ] Migrate each secret to QuantumVault (Article 2 process)
[ ] Replace variable values with QuantumVault IDs
[ ] Keep only QUANTUMAPI_KEY in ADO variable group
[ ] Verify: git grep -r "password\|secret\|connectionstring" pipeline YAML → 0

IMAGE SIGNING
[ ] Generate ML-DSA signing key pair in QuantumVault
[ ] Add Cosign signing stage (after image push, before deploy)
[ ] Add Cosign verification step (before kubectl apply)
[ ] Store signing key private PEM in QuantumVault (not on disk longer than needed)

SBOM
[ ] Install CycloneDX tool in build stage
[ ] Generate SBOM after successful build
[ ] Attach SBOM as OCI attestation to image in ACR
[ ] Publish SBOM as build artifact (audit trail)

SUPPLY CHAIN
[ ] All pipeline tools pinned to specific versions (qapi, cosign, trivy)
[ ] Base images pinned by digest (FROM mcr.microsoft.com/dotnet/aspnet@sha256:...)
[ ] NuGet packages pinned in packages.lock.json (dotnet restore --locked-mode)

VERIFY
[ ] Pipeline succeeds end-to-end
[ ] Cosign verify passes for the deployed image
[ ] No secrets visible in pipeline logs
[ ] SBOM attached to image in ACR (cosign download attestation)

Challenge

Ahora tienes una aplicación quantum-safe (secretos, cifrado, identidad) y un pipeline quantum-safe (secretos con QuantumVault, imágenes firmadas, SBOM).

Pero dentro del cluster de AKS, los servicios se comunican sin verificar la identidad del otro. Un pod comprometido puede llamar a cualquier otro servicio. Las network policies limitan qué pods pueden hablar entre sí, pero no verifican identidad.

El artículo 6 añade la última capa: mTLS entre servicios con certificados ML-DSA. Cada servicio demuestra su identidad en cada conexión. Zero trust en la capa de comunicación servicio a servicio.

Si esta serie te resulta útil, puedes invitarme a un café.

Comments

Loading comments...