To build a SaaSBOM in CycloneDX, write a BOM whose services array describes each running service: its endpoints, authentication, trust zone and classified data flows. Generate a draft with cdxgen, fill in the rest by hand, link each service to its own SBOM with BOM-Link, then validate against the schema.
This guide is the practical companion to our explainers on what a SaaSBOM is and whether your service inventory needs one and whether to ask SaaS vendors for one. Those posts cover the why. This one is the how: every field, the commands that produce a draft, the parts no tool can fill in for you, and a complete three service example that validates against the CycloneDX 1.7 JSON schema.
Key takeaways
- A SaaSBOM is not a separate format. It is an ordinary CycloneDX BOM that uses the
servicesarray (present in the schema since CycloneDX 1.2) instead of, or alongside,components. - The only required service field is
name. Everything that makes a SaaSBOM useful (endpoints,authenticated,x-trust-boundary,trustZone,data) is optional, so your review process has to require it. - Each entry in
datamust carry aflow(inbound, outbound, bi-directional or unknown) and aclassification. The classification is a free text string, so pick a vocabulary and stick to it. - Since CycloneDX 1.5, a data flow can name its
sourceanddestinationas a URL or a BOM-Link element, and can record data owners, stewards and custodians undergovernance. - cdxgen can draft services from source (the
saasbomcommand andevinse) and from docker-compose, Kubernetes and Skaffold manifests. It cannot know your trust zones, your data classification or who owns the data. People fill those in. - BOM-Link (
urn:cdx:serialNumber/version#bom-ref) ties each service in the SaaSBOM to the SBOM of the code that runs it, without copying that SBOM in. - Refresh the SaaSBOM on every release that changes an endpoint, a flow or a provider, and give it one named owner per service.
What a SaaSBOM actually is in CycloneDX terms
CycloneDX describes the SaaSBOM capability as a way to "inventory services, endpoints, and data flows and classifications" for cloud native applications, according to the CycloneDX SaaSBOM capability page. The same page lists the outcomes it is meant to support: spotting misconfigured services, insecure APIs and unprotected data exchanges, and fitting into Infrastructure as Code and microservice designs.
There is no separate SaaSBOM document type. A CycloneDX BOM becomes a SaaSBOM when it describes services: what is running, where it listens, who can call it, and what data crosses which boundary. Many real BOMs carry both components and services.
The current version is CycloneDX 1.7, released on 2025-10-21 according to the CycloneDX specification overview. Ecma International published it as ECMA-424, 2nd edition, in December 2025; the 1st edition dates from June 2024. Everything in this guide is checked against the 1.7 JSON schema in the CycloneDX specification repository, and the worked example also validates against 1.6.
If you are new to the idea of BOMs for things you do not ship as a download, read the SBOM story for a SaaS startup with nothing to download and SBOMs for SaaS products first.
Which CycloneDX service fields exist
Here is every property on the service object in the CycloneDX 1.7 schema, what type it takes, and what you should put in it for a SaaSBOM. I compared the service definition across the published schema files for 1.2 to 1.7 to fill in the "since" column.
| Field | Type | Since | What to put in it |
|---|---|---|---|
bom-ref | string | 1.2 | A stable identifier, unique in this BOM, used by dependencies, compositions and BOM-Links. Must not start with urn:cdx: |
provider | organizational entity | 1.2 | The organization that runs the service: your company for first party services, the vendor for third party APIs |
group | string | 1.2 | A namespace such as your team or product short name |
name | string | 1.2 | Required. The service name |
version | string | 1.2 | The deployed release or API version |
description | string | 1.2 | One sentence on what the service does |
endpoints | array of IRI references | 1.2 | Every URI the service answers on |
authenticated | boolean | 1.2 | true if callers must authenticate before use |
x-trust-boundary | boolean | 1.2 | true if using the service crosses a trust boundary |
trustZone | string | 1.5 | The name of the trust zone the service lives in |
data | array of service data | 1.2 (expanded in 1.5) | The classified data flows in and out |
licenses | license choice | 1.2 | Rarely relevant for a hosted service; can carry terms of use |
externalReferences | array | 1.2 | Links to docs, the OpenAPI file, status page, and the service's own SBOM (type bom) |
services | array of services | 1.2 | Services deployed behind this one, as a hierarchy, not a dependency graph |
properties | name and value pairs | 1.3 | Anything the standard does not model, such as retention periods |
releaseNotes | release notes | 1.4 | Changes in this version of the service |
signature | JSF signature | 1.4 | An enveloped signature over this service object |
tags | array of strings | 1.6 | Short labels such as pci-scope |
patentAssertions | patent assertions | 1.7 | Patent claims related to the service; rarely used in a SaaSBOM |
Three things stand out once you read the schema text rather than a summary.
First, only name is required. A BOM with forty services that each have a name and nothing else is schema valid and almost useless. Validation tells you the document is well formed; it does not tell you it is complete. Your own review has to enforce the fields you care about.
Second, services nested inside a service "is not a dependency tree", in the schema's own words. It describes assemblies: an API gateway with three services deployed behind it, for example. Calls between services belong in the top level dependencies array, which accepts a service's bom-ref in the same way it accepts a component's.
Third, trustZone arrived in 1.5, along with the richer data flow object. If a consumer insists on 1.4, you lose both, and cdxgen's documentation says it only emits 1.4 and 1.5 as downgrades of a 1.6 or later BOM.
endpoints
Each endpoint is an IRI reference. In practice that means full URLs such as https://orders.internal.acme.example/v2/orders. Two details trip people up:
- Route templates. A path with a placeholder in braces is not a valid IRI. cdxgen's evinse guide says it percent encodes every endpoint for this reason, so a users route with an id placeholder is written with
%7Band%7Din place of the braces, while the servicenamekeeps the readable template. Do the same when you write endpoints by hand. - Secrets in URLs. Never put credentials, tokens or signed query strings in an endpoint. The same evinse guide notes that for .NET projects cdxgen strips URL credentials, query strings and fragments before writing endpoints. Treat that as the rule for your hand edits as well.
List internal endpoints as well as public ones. An internal endpoint that an attacker can reach from a compromised pod is exactly the kind of thing a threat model review wants to see.
authenticated
A boolean. true means the service requires authentication before use; false means it does not. There is no field for the authentication method, scopes or token lifetime. If you need those, put them in properties with a namespaced name, for example acme:auth-method with the value oauth2-client-credentials. Keep the boolean honest: a service with one unauthenticated health route and authenticated business routes is usually best split into two services, or described with authenticated: true plus a property naming the open routes.
x-trust-boundary and trustZone
These two look similar and answer different questions.
x-trust-boundaryis a yes or no: does using this service cross a trust boundary? A call from your public edge into a payment zone does. A call between two services in the same internal network usually does not.trustZoneis a name: where does the service live? The schema gives no list of allowed values, so you define the vocabulary. Typical zones arepublic-edge,internal,pci,restricted-dataandthird-party.
Use both. The zone lets a reviewer group services; the boolean marks the edges a reviewer must look at first. If your threat model diagrams already draw trust boundaries, copy their names into trustZone so the diagram and the BOM use the same words.
data
data is an array of service data objects. Each one needs flow and classification, and since 1.5 can carry name, description, governance, source and destination. The next section covers it in depth because it is where most of the value sits.
externalReferences, properties, tags, releaseNotes and signature
externalReferences is how a service points at things outside the BOM. Useful types for a SaaSBOM include documentation, website, support, security-contact and, most important, bom, which can hold a BOM-Link to the SBOM of the code running behind the service. properties carries anything the schema does not model. tags (1.6 and later) gives you cheap filters. releaseNotes lets you record what changed in a service version. signature lets you sign an individual service object with JSON Signature Format, which cdxgen's documentation says its cdx-sign command supports at the level of individual services.
How data flow and classification are recorded
Here is the full shape of one data flow entry as the 1.7 schema defines it:
{
"flow": "outbound",
"classification": "Confidential",
"name": "Payment intent",
"description": "Order total and a card token; never the card number",
"governance": {
"owners": [{ "organization": { "name": "Acme Example Inc." } }],
"stewards": [{ "contact": { "name": "Payments data steward", "email": "payments-data@acme.example" } }],
"custodians": [{ "contact": { "name": "Payments platform team", "email": "payments-team@acme.example" } }]
},
"destination": ["urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-payments-api"]
}
flow
flow is an enum with four values, and the direction is always relative to the service that holds the entry:
| Value | Meaning in the schema |
|---|---|
inbound | Data enters the service |
outbound | Data leaves the service |
bi-directional | Data flows in and out |
unknown | The direction is not known |
Because direction is relative, the same exchange appears twice: as outbound on the caller and inbound on the callee. That duplication is deliberate. It lets someone read one service in isolation and still see everything it sends and receives. It also gives you a free consistency check, which the walkthrough below uses.
classification
The schema describes classification as tagging data "according to its type, sensitivity, and value if altered, stolen, or destroyed", and types it as a plain string. CycloneDX does not ship a list. That freedom is the main source of messy SaaSBOMs: one team writes PII, another personal data, a third Confidential.
Fix it before the first BOM is written. Take the classification scheme your company already uses for data handling and publish the exact strings. A small, closed set works best, for example Public, Internal, Confidential, PII and PCI. Then make a CI check reject anything else.
governance
Since 1.5, each flow can name three kinds of responsible party, each either an organization or an individual contact:
- Owners are "concerned with risk and appropriate access to data".
- Stewards are responsible for "data content, context, and associated business rules".
- Custodians are responsible for "the safe custody, transport, and storage of data".
Those quotes are the schema's own definitions. In practice, the owner is often a business unit, the steward a data or product lead, and the custodian the platform team that runs the database. Filling these in turns a SaaSBOM into something a privacy review can work from directly.
source and destination
Each is an array, and each item is either a URL or a BOM-Link element. There is one subtle point here. Within the same BOM, dependencies refer to services by bare bom-ref, but source and destination are typed as a URL or a BOM-Link element, not as a bare reference. So to say "this flow goes to the payments service described in this same SaaSBOM", write a BOM-Link that uses this BOM's own serial number and version, followed by #svc-payments-api. The worked example does exactly that, for instance urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-payments-api.
How this helps a privacy or threat model review
A threat model review usually starts from a data flow diagram: processes, data stores, external entities, the flows between them and the trust boundaries they cross. A SaaSBOM carries most of that diagram as data. Services are the processes and external entities, data entries are the flows, trustZone and x-trust-boundary are the boundaries, and authenticated is a first answer to the spoofing question at each entry point. A reviewer can filter for every flow with x-trust-boundary: true and a sensitive classification and start there, instead of rebuilding the diagram from interviews.
For privacy work, the questions are different but the data is the same: where does personal data go, who owns it, which third parties receive it. Filter data for your personal data classifications, follow destination to the receiving services, and read provider on each to see whether the data leaves your organization. governance answers "who do I ask". properties can hold retention periods if your process needs them.
None of this replaces the review itself. A SaaSBOM records what you declare. It cannot prove that the payments service really never stores a card number. It does make the declaration explicit, versioned and diffable, so the next review starts from last quarter's answers instead of a blank page.
How to generate a first draft from source with cdxgen
cdxgen is the CycloneDX project's polyglot generator, maintained under the CycloneDX GitHub organization. Its README lists SaaSBOM generation "for Java, Python, JavaScript, TypeScript, and PHP projects", and its evinse guide adds that .NET API endpoints found by the dosai analyzer are converted into CycloneDX services. The default output is CycloneDX 1.7.
There are two routes, and both are documented in the cdxgen repository.
Route 1: the saasbom command
According to the cdxgen CLI documentation, saasbom is an alias for cdxgen with evidence, deep mode and CycloneDX 1.7 defaults "suited for service-evidence collection", and it accepts the normal cdxgen flags. It ships as an npm command and as a standalone release binary that bundles the atom analysis engine.
# Install cdxgen (the README's recommended form)
npm install -g @cdxgen/cdxgen --ignore-scripts --min-release-age=2
# Draft a SaaSBOM for a Java service
cd services/orders-api
saasbom -t java -o saasbom.draft.json .
# Look at what it found
jq '.services[]? | {name, endpoints}' saasbom.draft.json
The README notes that the standalone saasbom binary needs no JDK on most Linux, macOS arm64 and Windows amd64 builds, and needs Java 23 or later on the few platform builds that still run atom on the JVM.
Route 2: generate an SBOM, then enrich it with evinse
evinse ("Evinse Verification Is Nearly SBOM Evidence") enriches an existing CycloneDX BOM with occurrence evidence, call stacks, reachability and service metadata. The advanced guide lists its prerequisites: the atom companion, the application source, and an input SBOM in CycloneDX 1.5 or later.
# Java: generate a deep SBOM with evidence, then enrich it
cdxgen -t java --deep -o bom.json --evidence .
evinse -i bom.json -o bom.evinse.json -l java --with-data-flow .
# JavaScript or TypeScript
cdxgen -t js -o bom.json .
evinse -i bom.json -o bom.evinse.json -l javascript --with-data-flow .
# .NET: deep mode lets dosai add API endpoint services
cdxgen -t dotnet --deep --evidence -o bom.json .
evinse -i bom.json -o bom.evinse.json -l dotnet --profile research .
If you already maintain an OpenAPI document, evinse can reuse it: its option table lists an OpenAPI spec file option that defaults to openapi.json, and the guide says routes from that file merge with the routes found in code into one service each.
To review the result interactively, cdxgen ships a REPL, cdxi, with a .services command:
cdxi bom.evinse.json
# then, at the prompt:
.services
What the source draft gives you, and what it does not
From the docs, the source route is about finding API surfaces: service names derived from routes, and endpoints. That is the tedious part, and having it machine generated means new routes show up without anyone remembering to add them. The documentation does not promise trust zones, data classification, governance or the direction of business data flows, and you should not expect a static analyzer to infer them. Treat the draft as the list of endpoints and nothing more until a person has reviewed it.
How to generate a draft from deployment manifests
Source code tells you what a service exposes. Deployment manifests tell you which services exist and run together. cdxgen's README says it "can automatically detect names of services from YAML manifests such as docker-compose, Kubernetes, or Skaffold manifests" and places them under services. Its project types table lists the matching types: docker-compose, kubernetes, skaffold, kustomize, openshift, helm and others, plus yaml-manifest, which also picks up openapi*.json and openapi*.yaml files.
# From a docker-compose file at the repository root
cdxgen -t docker-compose -o compose.cdx.json .
# From a directory of Kubernetes manifests
cdxgen -t kubernetes -o k8s.cdx.json deploy/k8s
# Collect everything: package, container and Kubernetes manifests
cdxgen -t universal -o universal.cdx.json .
# List the service names found
jq -r '.services[]?.name' k8s.cdx.json | sort -u
The universal type, in the README's words, opportunistically collects "as many components and services as possible", and the result "could have over a thousand components". Use it to discover what exists, not as the SaaSBOM you publish.
From manifests you mainly get the list of services that are deployed together. That is the backbone of the SaaSBOM: one entry per service, which you then enrich with the endpoints from the source route and the human answers below.
What must be filled in by hand
This table is the honest split between what tools can find and what only people know.
| Field | Can a generator infer it? | Who usually knows |
|---|---|---|
name, group | Yes, from manifests and routes | Generator, then checked by the service team |
endpoints | Partly: routes from code and OpenAPI, not hostnames per environment | Service team plus platform team for real hostnames |
version | Sometimes, from the build | Release pipeline |
provider | No for third parties | Service team, procurement for vendors |
authenticated | Not reliably | Service team, API gateway owner |
x-trust-boundary | No | Security architect |
trustZone | No | Security architect, network team |
data[].flow | Not for business data | Service team |
data[].classification | No | Data owner, privacy team |
data[].governance | No | Data owner |
data[].source, destination | No | Service team |
dependencies between services | Partly, from config and service mesh data | Service team, platform team |
externalReferences of type bom | Yes, if your pipeline records SBOM serial numbers | Release pipeline |
The practical pattern that follows from this table: keep a small, hand-maintained SaaSBOM file in version control as the source of truth for the human fields, and use generated drafts only to detect drift, such as a new endpoint or a new service that the hand-maintained file does not mention. The walkthrough below implements that pattern.
How to tie a SaaSBOM to the SBOM of each service with BOM-Link
A SaaSBOM describes services. The code inside each service is described by that service's SBOM, which your build already produces. You do not want to copy hundreds of components into the SaaSBOM. You want a pointer. That is what BOM-Link is for.
According to the CycloneDX BOM-Link page, BOM-Link is a formally registered URN, governed by IANA and compliant with RFC 8141, with this syntax:
urn:cdx:serialNumber/version#bom-ref
The serial number is the target BOM's serialNumber without the urn:uuid: prefix, the version is the target BOM's version (default 1), and the optional fragment names one object inside that BOM by its bom-ref. The 1.7 schema enforces the pattern: a BOM-Link document must match urn:cdx: followed by a lowercase UUID, a slash and a version number of 1 or more; a BOM-Link element adds a # and a reference.
Use it in three places in a SaaSBOM:
- Service to SBOM. In each service's
externalReferences, add an entry withtype: "bom"and the BOM-Link document of that service's SBOM. This is the main link. - Data flow ends. In
data[].sourceanddata[].destination, use BOM-Link elements to point at a service in this or another BOM. - Cross-team SaaSBOMs. If another team publishes its own SaaSBOM, point at its services with BOM-Link elements instead of duplicating them.
There is one constraint worth knowing. In dependencies, the ref and dependsOn values are typed as references to objects in the same BOM. So the call graph between services lives inside the SaaSBOM, and the link from a service to its code lives in externalReferences.
For BOM-Links to stay valid, two habits matter:
- Keep serial numbers stable per artifact. A new build of a service that produces a new SBOM gets a new serial number. Correcting an existing SBOM for the same build should keep the serial number and increase
version. - Store SBOMs where consumers can resolve the serial number. A BOM-Link is a name, not a download location. Whoever reads the SaaSBOM needs a repository or portal where they can look up a BOM by serial number and version.
If you prefer one self-contained document, the CycloneDX CLI can merge BOMs, including a hierarchical merge that needs a name and version for the result. That makes a large file that goes stale as soon as one service rebuilds, so for SaaS products that deploy often, links usually age better than merges. If you have not yet set up one SBOM per service, start with our microservices SBOM guide, which covers per-service SBOM generation, shared base images and multi-team workflows.
A full worked example: a three service checkout app
The example product, acme-checkout, has three first party services and one third party API:
| Service | Trust zone | Authenticated | Crosses boundary | Talks to |
|---|---|---|---|---|
web-bff (browser facing backend) | public-edge | yes | yes | orders-api |
orders-api | internal | yes | no | payments-api |
payments-api | pci | yes | yes | card-authorization |
card-authorization (third party) | third-party | yes | yes | none described |
And the data flows, written once from each side:
| Flow | From | To | Classification |
|---|---|---|---|
| Shopper checkout form | the shopper's browser | web-bff | PII |
| Order request | web-bff | orders-api | PII |
| Payment intent (order total plus card token) | orders-api | payments-api | Confidential |
| Card authorization | payments-api | card-authorization | PCI |
Here is the complete SaaSBOM. It validates against the CycloneDX 1.7 JSON schema (bom-1.7.schema.json from the specification repository), and also against 1.6 if you change specVersion to 1.6.
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
"version": 1,
"metadata": {
"timestamp": "2026-10-23T09:00:00Z",
"lifecycles": [{ "phase": "operations" }],
"supplier": { "name": "Acme Example Inc.", "url": ["https://acme.example"] },
"component": {
"type": "application",
"bom-ref": "acme-checkout",
"name": "acme-checkout",
"version": "2026.10.2"
}
},
"services": [
{
"bom-ref": "svc-web-bff",
"provider": { "name": "Acme Example Inc." },
"group": "acme",
"name": "web-bff",
"version": "4.12.0",
"description": "Browser facing backend for the checkout UI",
"endpoints": ["https://checkout.acme.example/api/cart", "https://checkout.acme.example/api/checkout"],
"authenticated": true,
"x-trust-boundary": true,
"trustZone": "public-edge",
"data": [
{
"flow": "inbound",
"classification": "PII",
"name": "Shopper checkout form",
"description": "Name, shipping address and email typed by the shopper"
},
{
"flow": "outbound",
"classification": "PII",
"name": "Order request",
"destination": ["urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-orders-api"]
}
],
"externalReferences": [
{ "type": "bom", "url": "urn:cdx:9b1c6a2e-4f0d-4c1e-8a57-2d6f3b8e1a01/1", "comment": "SBOM of web-bff 4.12.0" }
]
},
{
"bom-ref": "svc-orders-api",
"provider": { "name": "Acme Example Inc." },
"group": "acme",
"name": "orders-api",
"version": "2.31.4",
"endpoints": [
"https://orders.internal.acme.example/v2/orders",
"https://orders.internal.acme.example/v2/orders/%7BorderId%7D"
],
"authenticated": true,
"x-trust-boundary": false,
"trustZone": "internal",
"data": [
{
"flow": "inbound",
"classification": "PII",
"name": "Order request",
"source": ["urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-web-bff"],
"governance": {
"owners": [{ "organization": { "name": "Acme Example Inc." } }],
"custodians": [{ "contact": { "name": "Orders platform team", "email": "orders-team@acme.example" } }]
}
},
{
"flow": "outbound",
"classification": "Confidential",
"name": "Payment intent",
"description": "Order total and a card token; never the card number",
"destination": ["urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-payments-api"]
}
],
"externalReferences": [
{ "type": "bom", "url": "urn:cdx:5c2d7e3f-1a2b-4c3d-9e4f-6a7b8c9d0e12/1", "comment": "SBOM of orders-api 2.31.4" }
],
"properties": [{ "name": "acme:data-retention-days", "value": "365" }]
},
{
"bom-ref": "svc-payments-api",
"provider": { "name": "Acme Example Inc." },
"group": "acme",
"name": "payments-api",
"version": "1.9.0",
"endpoints": ["https://payments.pci.acme.example/v1/charges"],
"authenticated": true,
"x-trust-boundary": true,
"trustZone": "pci",
"data": [
{
"flow": "inbound",
"classification": "Confidential",
"name": "Payment intent",
"source": ["urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-orders-api"]
},
{
"flow": "bi-directional",
"classification": "PCI",
"name": "Card authorization",
"destination": ["urn:cdx:3e671687-395b-41f5-a30f-a58921a69b79/1#svc-card-processor"]
}
],
"externalReferences": [
{ "type": "bom", "url": "urn:cdx:7d3e8f40-2b3c-4d5e-8f60-7b8c9d0e1f23/1", "comment": "SBOM of payments-api 1.9.0" }
],
"tags": ["pci-scope"]
},
{
"bom-ref": "svc-card-processor",
"provider": { "name": "Example Payments Ltd.", "url": ["https://payments.example"] },
"name": "card-authorization",
"endpoints": ["https://api.payments.example/v1/authorize"],
"authenticated": true,
"x-trust-boundary": true,
"trustZone": "third-party",
"data": [{ "flow": "bi-directional", "classification": "PCI" }]
}
],
"dependencies": [
{ "ref": "acme-checkout", "dependsOn": ["svc-web-bff"] },
{ "ref": "svc-web-bff", "dependsOn": ["svc-orders-api"] },
{ "ref": "svc-orders-api", "dependsOn": ["svc-payments-api"] },
{ "ref": "svc-payments-api", "dependsOn": ["svc-card-processor"] },
{ "ref": "svc-card-processor", "dependsOn": [] }
],
"compositions": [
{ "aggregate": "complete", "assemblies": ["svc-web-bff", "svc-orders-api", "svc-payments-api"] },
{ "aggregate": "unknown", "assemblies": ["svc-card-processor"] }
]
}
A few choices in this file are worth explaining.
metadata.lifecycles is operations. The 1.7 schema defines the operations phase as a BOM that "represents inventory that is running and operational". A SaaSBOM written at design time, before anything is deployed, would use design instead.
The call graph is in dependencies. Each service lists the services it calls. The third party processor is declared with an empty dependsOn, because the schema says components or services without dependencies "must be declared as empty elements within the graph"; leaving it out would mean "unknown", not "none".
compositions says what you know. The three first party services are marked complete. The third party processor is marked unknown, because you have no idea what runs behind the vendor's endpoint. This is the honest way to show the edge of your knowledge, and a reader can tell the difference between "no further services" and "not described".
Flow ends are BOM-Link elements. destination on web-bff points at svc-orders-api with a BOM-Link built from this SaaSBOM's own serial number and version, as discussed above.
Each first party service links to its own SBOM. Here is the minimal SBOM that the orders-api link resolves to. It also validates against CycloneDX 1.7. A real one from cdxgen would list every dependency; this one keeps a single component to stay readable.
{
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": "urn:uuid:5c2d7e3f-1a2b-4c3d-9e4f-6a7b8c9d0e12",
"version": 1,
"metadata": {
"timestamp": "2026-10-23T08:41:12Z",
"lifecycles": [{ "phase": "build" }],
"component": {
"type": "application",
"bom-ref": "orders-api",
"name": "orders-api",
"version": "2.31.4"
}
},
"components": [
{
"type": "library",
"bom-ref": "pkg:npm/express@4.21.2",
"name": "express",
"version": "4.21.2",
"purl": "pkg:npm/express@4.21.2"
}
],
"dependencies": [
{ "ref": "orders-api", "dependsOn": ["pkg:npm/express@4.21.2"] },
{ "ref": "pkg:npm/express@4.21.2", "dependsOn": [] }
]
}
The SaaSBOM's link urn:cdx:5c2d7e3f-1a2b-4c3d-9e4f-6a7b8c9d0e12/1 resolves to exactly this file: same serial number, version 1. If you wanted to point at the application component inside it, the element link would be urn:cdx:5c2d7e3f-1a2b-4c3d-9e4f-6a7b8c9d0e12/1#orders-api.
Validate it
The CycloneDX CLI has a validate command with an input version flag that accepts versions up to v1_7 and a flag that returns a non-zero exit code on errors. It can be installed from the CycloneDX Homebrew tap or run as the cyclonedx/cyclonedx-cli Docker image, according to its README.
# Homebrew
brew install cyclonedx/cyclonedx/cyclonedx-cli
cyclonedx validate --input-file saasbom.cdx.json --input-version v1_7 --fail-on-errors
# Or with Docker
docker run --rm -v "$PWD":/data cyclonedx/cyclonedx-cli \
validate --input-file /data/saasbom.cdx.json --input-version v1_7 --fail-on-errors
# cdxgen ships its own validator with structural and compliance checks
cdx-validate -i saasbom.cdx.json
For this post I validated both files with a standard JSON Schema draft 07 validator against bom-1.7.schema.json and its referenced spdx.schema.json, jsf-0.82.schema.json and cryptography-defs.schema.json from the specification repository. Both passed. As a negative test, changing one flow to an invalid value and one x-trust-boundary to a string produced the expected enum and type errors, so the check is doing real work.
Step by step: from nothing to a maintained SaaSBOM
This is the whole process as a repeatable routine. It assumes a monorepo with one directory per service and a deploy/ directory of Kubernetes manifests; adjust the paths to your layout.
Step 1. Agree the vocabularies. Write down the allowed trustZone names and classification strings in a short file, for example saasbom-vocab.json. Everything later checks against it.
{
"trustZones": ["public-edge", "internal", "pci", "third-party"],
"classifications": ["Public", "Internal", "Confidential", "PII", "PCI"]
}
Step 2. List the services from manifests.
cdxgen -t kubernetes -o build/k8s.cdx.json deploy/
jq -r '.services[]?.name' build/k8s.cdx.json | sort -u > build/deployed-services.txt
Step 3. Draft endpoints from each service's source.
for svc in services/*/; do
name=$(basename "$svc")
saasbom -o "build/$name.saasbom.draft.json" "$svc"
done
jq -r '.services[]?.endpoints[]?' build/*.saasbom.draft.json | sort -u > build/found-endpoints.txt
Step 4. Write the hand-maintained SaaSBOM. Start from the worked example above. For each service in deployed-services.txt, add a services entry, copy in the endpoints you want to declare, and fill in provider, authenticated, x-trust-boundary, trustZone and data. Commit it as saasbom.cdx.json next to the code, owned in CODEOWNERS by the people who must approve changes.
Step 5. Record each service's SBOM serial number. Your build already produces SBOMs. Read the serial number from each and write the BOM-Link into the matching service.
serial=$(jq -r '.serialNumber | sub("^urn:uuid:"; "")' build/orders-api.sbom.cdx.json)
version=$(jq -r '.version' build/orders-api.sbom.cdx.json)
jq --arg link "urn:cdx:$serial/$version" '
(.services[] | select(.name == "orders-api") | .externalReferences) =
[{ "type": "bom", "url": $link, "comment": "SBOM of orders-api" }]
' saasbom.cdx.json > saasbom.tmp && mv saasbom.tmp saasbom.cdx.json
Step 6. Check drift between code and declaration. Any endpoint the generator found that the SaaSBOM does not declare is a review item.
jq -r '.services[]?.endpoints[]?' saasbom.cdx.json | sort -u > build/declared-endpoints.txt
comm -23 build/found-endpoints.txt build/declared-endpoints.txt > build/undeclared.txt
test ! -s build/undeclared.txt || { echo "Undeclared endpoints:"; cat build/undeclared.txt; exit 1; }
The generator may report paths while your SaaSBOM records full URLs. Normalize both sides to the same form (for example, strip the scheme and host) before comparing.
Step 7. Check vocabulary and flow symmetry.
# Every trustZone and classification must come from the agreed vocabulary
jq -e --slurpfile v saasbom-vocab.json '
[.services[] | .trustZone // empty] - $v[0].trustZones | length == 0
' saasbom.cdx.json
jq -e --slurpfile v saasbom-vocab.json '
[.services[].data[]?.classification] - $v[0].classifications | length == 0
' saasbom.cdx.json
# Every service that crosses a trust boundary must say whether it is authenticated
jq -e '[.services[] | select(."x-trust-boundary" == true and (has("authenticated") | not))] | length == 0' saasbom.cdx.json
A symmetry check is also worth writing: for each outbound flow with a destination inside this BOM, the destination service should have an inbound or bi-directional flow with the same name. That catches half-edited flows after a refactor.
Step 8. Validate against the schema. Use the CycloneDX CLI command shown above, with the fail on errors flag so CI stops on a broken file.
Step 9. Wire it into CI. A minimal GitHub Actions job:
name: saasbom
on:
pull_request:
paths: ["services/**", "deploy/**", "saasbom.cdx.json"]
push:
branches: [main]
jobs:
saasbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install cdxgen
run: npm install -g @cdxgen/cdxgen --ignore-scripts
- name: Draft services and endpoints
run: |
mkdir -p build
cdxgen -t kubernetes -o build/k8s.cdx.json deploy/
for svc in services/*/; do
saasbom -o "build/$(basename "$svc").saasbom.draft.json" "$svc"
done
- name: Drift and vocabulary checks
run: ./scripts/saasbom-checks.sh
- name: Validate against CycloneDX 1.7
run: |
docker run --rm -v "$PWD":/data cyclonedx/cyclonedx-cli \
validate --input-file /data/saasbom.cdx.json --input-version v1_7 --fail-on-errors
- uses: actions/upload-artifact@v4
with:
name: saasbom
path: |
saasbom.cdx.json
build/*.json
scripts/saasbom-checks.sh holds steps 6 and 7. The saasbom command needs the atom analysis engine, which the cdxgen README says is bundled in the standalone saasbom release binary; if your npm install on the runner lacks it, download that binary or use the cdxgen container image instead. The job fails a pull request that adds an undeclared endpoint or an unknown classification, which is exactly when you want a human to look.
Step 10. Publish with the right sharing controls. CycloneDX 1.7 added metadata.distributionConstraints with a tlp value (CLEAR, GREEN, AMBER, AMBER_AND_STRICT or RED, default CLEAR). A SaaSBOM lists internal hostnames and trust zones, so for most companies anything shared outside the security team should carry at least AMBER, and you may want a reduced external version without internal endpoints.
How often to refresh, and who owns it
A SaaSBOM that is six months old is worse than none, because people trust it. Tie updates to events, not to the calendar.
| Trigger | What changes in the SaaSBOM | Who acts |
|---|---|---|
| New or removed endpoint | endpoints; maybe a new service | Service team, caught by the drift check |
| New service deployed | New services entry, dependencies, compositions | Service team plus security architect |
| New third party API or vendor change | New service with external provider, flows, x-trust-boundary | Service team plus procurement or vendor risk |
| New type of data handled | data entries and classification | Data owner, privacy team |
| Service rebuilt | BOM-Link in externalReferences points to the new SBOM | Release pipeline, automatically |
| Network or zone change | trustZone, x-trust-boundary | Platform and network team |
| Quarterly review | Everything, read through by a person | Named SaaSBOM owner |
Two numbers need care on every change. version on the SaaSBOM goes up by one each time you republish the same document. serialNumber changes only when you start a genuinely new document, for instance after splitting one product's SaaSBOM in two. Because BOM-Links include the version, consumers who stored a link to version 3 can still tell it apart from version 4.
On ownership, CISA's SBOM Sharing Roles and Considerations (published 28 March 2024) defines three roles that map well onto a SaaSBOM: the author who creates it, the distributor who receives it in order to pass it on, and the consumer who uses it. It also splits sharing into three phases: discovery (how a consumer learns the document exists), access (who may see it) and transport (how it moves). For a SaaSBOM, the usual pattern is:
- Each service team authors its own entries, through code review on the shared file.
- One named owner, often in security architecture, authors the document as a whole and runs the quarterly review.
- A portal or repository acts as distributor to internal consumers (security, privacy, compliance) and, under a stricter TLP, to customers who ask.
CISA's SBOM Sharing Primer gives examples of sharing methods in use today, from email to public repositories. Choose deliberately: a SaaSBOM is closer to an architecture document than to a public SBOM.
SaaSBOM compared with the documents you already have
Teams often ask whether a SaaSBOM duplicates something they already maintain. It overlaps with several documents and replaces none of them.
| Document | Answers | Machine readable | Links to code inventory | Used for |
|---|---|---|---|---|
| SBOM (components) | What code is inside an artifact | Yes | It is the code inventory | Vulnerability and license management |
| SaaSBOM (services) | What runs, where it listens, what data crosses which boundary | Yes | Yes, through BOM-Link | Service inventory, threat model and privacy input, vendor list |
| OBOM | What is installed and configured on a running host or environment | Yes | Can link | Runtime and operations inventory |
| OpenAPI document | What one API accepts and returns | Yes | No | API contracts, client generation, endpoint discovery |
| Data flow diagram | How the system works, drawn for humans | Rarely | No | Threat modeling workshops |
| Vendor or subprocessor register | Which outside companies process your data | Sometimes | No | Contracts and privacy notices |
The SaaSBOM is the one place where services, endpoints, trust zones, data classification and links to code meet in a standard format. Feed it from the OpenAPI files and manifests you already have, and generate the diagram from it rather than the other way round.
Where Safeguard fits
Plainly: Safeguard does not generate SaaSBOMs, and it does not author the services, trust zone or data flow fields described in this post. That work belongs to cdxgen, your own files and your reviewers.
Where Safeguard does help is the layer underneath and around the SaaSBOM. SBOM generation and analysis is generally available: Safeguard produces CycloneDX (JSON, XML) and SPDX (JSON, XML, RDF) SBOMs for each repository and container image, with continuous scanning on push, on a schedule or on release, and more than 30 export formats including CSV, Excel and PDF. Those per-service SBOMs are what your SaaSBOM's BOM-Links should point at, and Safeguard keeps them current and matched against vulnerability data. Attestation and signing, also generally available, covers SLSA provenance, Sigstore keyless signing with Fulcio and Rekor, in-toto and X.509, so the SBOMs behind your links can be verified. Policies, gates and guardrails (generally available) can block a release when a service's SBOM shows a problem.
For third party services, Safeguard's third party risk management (generally available) lets you onboard vendors, request SBOMs from them, and score and monitor supplier risk. That is useful for the provider entries marked third-party in your SaaSBOM, where all you have is an endpoint and a name. The Safeguard portal (generally available) is a place to publish, share and manage SBOMs with audit trails; its Trust Center is in early access.
Common mistakes
- Shipping a names-only SaaSBOM. It validates, because only
nameis required, and it tells a reviewer nothing. Require endpoints,authenticated,trustZoneand at least onedataentry per service in your own checks. - Free-form classifications.
PII,pii,Personalandpersonal datain one file make filtering useless. Publish a vocabulary and enforce it in CI. - Using nested
servicesas a call graph. The schema says nesting is "not a dependency tree". Put calls independencies. - Omitting leaf services from
dependencies. A service with no dependencies must appear with an emptydependsOn; leaving it out means "unknown". - Claiming completeness you do not have. Mark third party services
unknownincompositionsunless the vendor has told you what runs behind their endpoint. - Raw route templates in endpoints. Braces are not valid in an IRI. Percent encode them, as cdxgen does.
- Bare
bom-refvalues insourceanddestination. These fields are typed as a URL or BOM-Link element. Use a BOM-Link built from the SaaSBOM's own serial number. - Changing the serial number on every edit. That breaks every stored BOM-Link. Keep the serial and increase
version. - Publishing internal hostnames at TLP CLEAR. Set
distributionConstraintsin 1.7 and keep a reduced external version. - Treating the generator output as final. Static analysis finds routes, not trust zones or business data. A person has to sign off.
FAQ
What is the difference between a SaaSBOM and an SBOM?
An SBOM inventories the components inside a piece of software. A SaaSBOM inventories the services a system runs or calls, with their endpoints, authentication, trust zones and data flows. In CycloneDX both are the same document format; a SaaSBOM simply fills the services array, and usually links to each service's SBOM through BOM-Link.
Do I need a SaaSBOM if I already have OpenAPI files?
OpenAPI describes what one API accepts and returns. It does not record trust zones, data classification, data owners, third party providers or links to the code inventory. Keep your OpenAPI files and use them as input: cdxgen's evinse can merge routes from an OpenAPI file into the services it writes.
Which CycloneDX version do I need for a SaaSBOM?
services exists from 1.2, but trustZone and data flow governance, source and destination arrived in 1.5, tags in 1.6 and distributionConstraints in 1.7. Use 1.6 or 1.7. cdxgen generates 1.7 by default.
Can cdxgen generate a SaaSBOM automatically?
It can generate a draft. The saasbom command and the evinse tool find services and endpoints in Java, Python, JavaScript, TypeScript and PHP source, and in .NET through dosai, and cdxgen detects service names from docker-compose, Kubernetes and Skaffold manifests. Trust zones, data classification, governance and most data flows still have to be filled in by people.
How do I classify data in a CycloneDX SaaSBOM?
Each entry in a service's data array needs a flow direction and a classification string. CycloneDX does not define the allowed strings, so use your company's existing data classification labels, publish the exact list, and reject anything else in CI.
How do I link a SaaSBOM to the SBOM of each microservice?
Add an externalReferences entry of type bom to each service, with a BOM-Link to that service's SBOM: urn:cdx: followed by the SBOM's serial number without the urn:uuid: prefix, a slash and its version. Add # and a bom-ref to point at one object inside it.
How often should a SaaSBOM be updated?
Whenever an endpoint, service, third party provider, data type or trust zone changes, and automatically whenever a service is rebuilt so its BOM-Link points at the new SBOM. Add a person-led review at least once a quarter.
Should I share my SaaSBOM with customers?
Share carefully. A SaaSBOM contains internal hostnames and your trust zone layout. Many teams keep a full internal version and a reduced external one, and mark both with a TLP level in distributionConstraints (CycloneDX 1.7).
Is a SaaSBOM required by regulation?
The sources used for this post do not name a SaaSBOM requirement. Regulatory SBOM guidance focuses on software components. A SaaSBOM is a good practice document for service inventory and review, and our post on SBOMs for SaaS products discusses how SaaS companies answer SBOM requests today.
Fix it without waiting for upstream
A SaaSBOM tells you which services exist and which SBOM describes each one, but it does not fix anything: when a service's SBOM shows a vulnerable dependency, someone still has to ship a rebuilt service. The fit here is indirect, because a SaaSBOM is an inventory document, yet the per-service SBOMs it links to are where software supply chain hygiene turns into fixed builds.
Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.
1. Autonomous remediation: pull requests, not tickets
When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.
2. Gold Open Source: the safe version in seconds, free
gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.
3. Gold Registry: start from components that are already fixed
registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.
When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.
| Your situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull hardened components and images from Gold Registry |
One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.
See how Safeguard would help
These demos show Safeguard working on the code behind your services, not on the SaaSBOM file itself. You can download Safeguard and read the CLI docs.
The first demo is a real Safeguard CLI session. You install the CLI, open the interactive terminal, ask it to fix the lodash CVE in a repository, and watch it read the finding and advisory, ask before each command, edit package.json to lodash 4.17.21, and run npm install and npm test with your approval. It pushes nothing, then shows /permissions, /model and /scan. The same flow applies when the SBOM linked from one of your SaaSBOM services shows a vulnerable dependency.
Click through it at your own pace, or open it full screen.
The second demo sets up the Safeguard runner on your own machine, approves it with a device code, and then scans from the terminal. If you build each service's SBOM in your own environment so the code never leaves it, this is the setup that produces the per-service inventories your BOM-Links point at.
Click through it at your own pace, or open it full screen.
The third demo connects Claude to the Safeguard MCP server with OAuth and lets you choose which tools Claude can use. With that in place, a reviewer working through a SaaSBOM can ask about the projects and vulnerabilities behind each linked service without leaving the conversation.
Click through it at your own pace, or open it full screen.
Quick check
Your SaaSBOM has 30 services, each with only a name, and it passes schema validation. Is it done?
No. Only name is required by the schema, so validation proves the file is well formed, not useful. Each service still needs endpoints, authenticated, trustZone, x-trust-boundary and classified data entries, plus a BOM-Link to its SBOM.
The orders service calls the payments service. Where do you record that, and how do you point a data flow at it?
Record the call in the top level dependencies array using both services' bom-ref values. In the orders service's data entry, set destination to a BOM-Link element built from the SaaSBOM's own serial number and version, ending in #svc-payments-api.
Sources
- CycloneDX: Software as a Service (SaaSBOM) capability
- CycloneDX: BOM-Link capability
- CycloneDX: Specification overview (version 1.7, release date, media types)
- CycloneDX specification repository on GitHub
- CycloneDX 1.7 JSON schema
- CycloneDX 1.6 JSON schema
- CycloneDX 1.5 JSON schema
- CycloneDX 1.4 JSON schema
- CycloneDX 1.3 JSON schema
- CycloneDX 1.2 JSON schema
- Ecma International: ECMA-424 CycloneDX Bill of materials specification
- cdxgen README
- cdxgen project types
- cdxgen CLI documentation (saasbom alias)
- cdxgen advanced usage (evinse mode and SaaSBOM)
- cdxgen evinse guide
- CycloneDX CLI README (validate and merge)
- CISA: SBOM Sharing Roles and Considerations
- CISA: SBOM Sharing Primer
- CISA: Software Bill of Materials
Last reviewed: 23 October 2026. Specifications and tools change; check the linked sources for the latest versions.