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.
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.
- 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.
- 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.
"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.
- 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.