Quantum-Safe Cloud -- Part 5

Quantum-Safe CI/CD: No More Secrets in Pipelines

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

The Problem

The pipeline from the ATLAS+GOTCHA series is a good start. It builds, tests, scans with Trivy, and deploys with zero-downtime rolling updates.

But it has three security gaps we didn’t address in that series.

Gap 1: Variable groups are not quantum-safe. Azure DevOps variable groups with Key Vault backing are encrypted at rest with Azure-managed keys — RSA by default. Better than plaintext in YAML. Not safe against harvest-now-decrypt-later. And the secrets themselves transit through Azure DevOps agent memory in plaintext.

Gap 2: Images are unsigned. Trivy scans the image for CVEs before pushing. But once the image is in ACR, anyone with write access can push a new image with the same tag. There’s nothing stopping a malicious image from replacing a clean one. The Kubernetes deployment has no way to verify that what it’s pulling is what the pipeline pushed.

Gap 3: Supply chain. The Trivy scan catches vulnerable packages. But it doesn’t verify that the base image hasn’t been tampered with, that the build agent hasn’t been compromised, or that the NuGet packages are the ones you expect. These are supply chain attack vectors. SolarWinds, Log4Shell, XZ Utils — they all exploited this gap.

This article fixes all three: QuantumVault for pipeline secrets, Cosign for image signing, and SBOM generation for supply chain transparency.

The Solution

Three additions to the existing pipeline:

  1. QuantumVault secret resolution: pipeline fetches secrets from QuantumVault at runtime via the qapi CLI. Zero secrets stored in Azure DevOps.

  2. Cosign image signing: after pushing to ACR, the pipeline signs the image with an ML-DSA key stored in QuantumVault. Kubernetes admission control verifies the signature before running any pod.

  3. SBOM generation: dotnet publish generates a CycloneDX SBOM. The SBOM is attached to the container image and pushed to ACR alongside the image. Audit trail for every dependency in production.

Execute

ATLAS for this task

[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

Step 1: Install the qapi CLI

Add an install step at the top of the 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

The QUANTUMAPI_KEY is the service account API key for your pipeline. This is the only secret that stays in Azure DevOps variable groups. Everything else is fetched from QuantumVault.

Step 2: Replace variable group secrets with QuantumVault

Before (article 6 pipeline):

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

After (quantum-safe pipeline):

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"

In the build stage, fetch secrets at runtime:

- 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

The secrets are fetched into ephemeral pipeline variables marked as issecret=true — Azure DevOps masks them in all log output. They exist only for the duration of the pipeline job.

Step 3: Generate SBOM

Add SBOM generation to the build stage, after the build succeeds:

        - 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

This generates a CycloneDX JSON SBOM listing every NuGet dependency with its version and known CVEs. The SBOM is published as a build artifact and also attached to the container image in the next stage.

Step 4: Sign the image with Cosign

Add a signing stage after the image push:

  # ─────────────────────────────────────────
  - 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"

Step 5: Verify the signature before deploying

In the deploy stage, verify the signature before running 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)

What the full secret flow looks like

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

What we adjusted

1. Key cleanup. The AI left the signing key file on disk across steps. We added rm -f /tmp/signing-key.pem immediately after Cosign finishes. On a shared agent pool, this matters.

2. SBOM as warning, not gate. The AI put SBOM generation before Trivy and made it a hard gate. We moved it after the build and made it advisory — a warning in the logs if it fails, not a pipeline failure. The reason: SBOM generation is useful but occasionally fails on complex transitive dependencies. Don’t let it block your deployments.

3. issecret=true on fetched variables. The AI used echo "##vso[task.setvariable variable=DB_CONNECTION]$DB_CONNECTION" — without issecret=true. This would have printed the connection string in the logs. Always mark fetched secrets as 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

You now have a quantum-safe application (secrets, encryption, identity) and a quantum-safe pipeline (QuantumVault secrets, signed images, SBOM).

But inside the AKS cluster, services communicate without verifying each other. A compromised pod can call any other service. Network policies limit which pods can talk, but they don’t verify identity.

Article 6 adds the last layer: mTLS between services with ML-DSA certificates. Every service proves its identity on every connection. Zero trust at the service-to-service layer.

If this series helps you, consider buying me a coffee.

Comments

Loading comments...