FROM THE DESK OFMERT’S MIND
FIELD NOTE / 005AZURE DEVOPS / CYCLONEDX
BRIT INSURANCE · DELIVERY

CycloneDX SBOM and container attestation in Azure Pipelines

The two FastAPI services I own ship from Azure DevOps. The bill of materials is generated from the image that pipeline built, and signed to that digest. A spreadsheet from last quarter is not a bill of materials.

By AZURE PIPELINES / COSIGN7 MIN READ

Project at a glance

Purpose
A bill of materials tied to the container image built by Azure Pipelines.
My contribution
SBOM generation and component checks for the FastAPI services I own, with agent attribution and digest-bound attestation.
Status and checks
The article describes pipeline validation and attestation. Pipeline run evidence and measured security outcomes are not published here.
Evidence
cdxgen, attribution, and Cosign pipeline excerpts below; examples are not a public attestation record.

I own the orchestrator and the persistence layer. Other teams call them. They leave Azure Pipelines as container images. Some of the pull requests now come from an agent. An agent can add a dependency between the last inventory someone exported and the build that actually ships. If the list lives in a docs folder, it is describing software we are not running.

01The lockfile is the PR check. The image is the release.

On a pull request I scan the lockfile. It is fast, and it shows a new direct dependency before merge. It also misses what the image contains: a vendored copy, a build-stage package, a library from the base image. The SBOM I keep is the one written after the image exists.

AZURE PIPELINESAFTER THE PUSH TO ACR
- script: |
    cdxgen \
      -t docker \
      $(registry)/$(image):$(Build.SourceVersion) \
      -o $(Build.ArtifactStagingDirectory)/sbom.cdx.json
  displayName: Generate SBOM from the image

CycloneDX, not SPDX, for this file. I want vulnerability metadata and a signature more than a license essay. The document gets a serial number per build, a timestamp, and the image as the root component. Build.SourceVersion is the commit. The digest is what the signature binds to. If I later patch the JSON, the document version increments. The image does not change to match an edited file.

02A malformed bill does not leave the pipeline.

Generation is not the gate. Validation is. The job fails if the document will not parse. I would rather stop there than hand a scanner a file it silently skips.

VALIDATEBEFORE ATTEST
- script: |
    cyclonedx-cli validate \
      --input-file $(Build.ArtifactStagingDirectory)/sbom.cdx.json \
      --fail-on-errors
  displayName: Validate SBOM

Between releases I diff the two documents. Added, removed, modified. That is the review when an agent merged a package on Tuesday. Monday’s file does not get to stand in for Tuesday’s image. Pull-request builds generate and validate. They do not sign.

03If an agent wrote it, the bill says so.

A dependency added by hand and a dependency added by an agent are not the same review. When the commit message marks the change as agent-generated, a step writes attribution onto the component. Which agent, which model, which commit, who reviewed it. Not the prompt. The prompt is not what an auditor needs, and it does not belong on an artifact we publish.

ATTRIBUTIONON THE COMPONENT
"x-ai-generated": {
  "agent": "coding-agent",
  "model": "gpt-4-code",
  "commit": "$(Build.SourceVersion)",
  "reviewed_by": "mert",
  "reviewed_at": "2026-04-29T11:30:00Z"
}

The failure I care about is a name that does not resolve. An agent will invent a package. The install fails, or the registry has nothing under that name. The build is the first place that shows up as a missing component rather than a line in a chat. No document, no image.

04Unsigned, it is just a JSON file.

Cosign attests the SBOM to the image. Keyless. The pipeline asks Azure DevOps for an OIDC token, Fulcio issues a short-lived certificate, Rekor keeps the entry. The identity is the service connection, not a key in a variable group. Verification pins the issuer to that Azure DevOps organisation, https://vstoken.dev.azure.com/, and the subject to the service connection. A regexp of .* is not a policy.

ATTESTSBOM BOUND TO THE IMAGE
- script: |
    cosign attest \
      --predicate $(Build.ArtifactStagingDirectory)/sbom.cdx.json \
      --type cyclonedx \
      --yes \
      $(registry)/$(image)@$(digest)
  displayName: Attest SBOM
  condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))

The file is published as a pipeline artifact and copied next to the release. Grype or Trivy can read it after the job. The gate I care about is a new critical finding, not the backlog we already know about.

END OF NOTE / 005RETURN TO THE INDEX ↑