Quantum-Safe Cloud -- Parte 5
CI/CD Quantum-Safe: Se Acabaron los Secretos en los Pipelines
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:
-
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. -
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.
-
Generación de SBOM:
dotnet publishgenera 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é.
Loading comments...