doc/development/sec/cyclonedx_license_ingestion.md
Focus: how a license declared in a CycloneDX SBOM becomes the
sbom_occurrences.licenses jsonb column. A CycloneDX component can declare a license in three
forms: an SPDX identifier (license.id), a free-text name (license.name), or an SPDX
expression (expression). The three forms diverge at parse time and converge during
ingestion, where an SPDX identifier drives a Package Metadata DB (PMDB) name and URL lookup
and an expression is gated behind a feature flag.
All paths are under ee/. Parsing produces a Reports::Sbom::License value object with no SPDX
resolution; ingestion resolves the source and writes the result to sbom_occurrences. A
separate read path (LicenseScanning::SbomScanner) reuses the same resolver to feed the merge
request license widget, the pipeline Licenses tab, and the license compliance report. Two flags
gate this path, both disabled by default: cyclonedx_license_expression_ingestion (whether an
expression survives into persisted licenses) and license_expression_checker (the
expression-aware policy checker downstream of ingestion).
flowchart TD
accTitle: CycloneDX license ingestion
accDescr: How a CycloneDX component license in id, name, or expression form is parsed, resolved through the license fetcher, and persisted to sbom_occurrences, plus the separate read path that feeds the license widgets.
A["Parsers::Sbom::Cyclonedx#parse_components"] --> B["Parsers::Sbom::Component#licenses
(reads component.licenses[])"]
B --> C["License::Common.parse (fork point)"]
C --> C1{"expression present?
(CycloneDX oneOf: expression XOR license)"}
C1 -->|"yes"| C2["Reports::Sbom::License(expression:)"]
C1 -->|"no"| C3["read license.id / name / url
check_license_name!: SPDX-valid name → id (Trivy 0.65.0 fix)"]
C2 --> D["Reports::Sbom::License
{ spdx_identifier, name, url, expression }"]
C3 --> D
subgraph ING["ingestion (Tasks::IngestOccurrences)"]
E["LicensesFetcher.fetch(component)
gated by security_setting.license_scanning_for_cyclonedx_enabled"] --> G{"component has
SBOM licenses?"}
G -->|"yes"| H["use SBOM licenses"]
G -->|"no"| I["PackageLicenses → PMDB pm_packages / pm_licenses
(else UNKNOWN_LICENSE)"]
H --> J["PackageLicenses#licenses_for_component
usable_licenses / include_expression?
(FF cyclonedx_license_expression_ingestion)"]
I --> J
J --> K["map_from: keep {spdx_identifier, name, url, expression}
name ||= expression (schema + non-null GraphQL name)"]
end
D --> E
K -->|"expression is persisted under the name attribute"| L[("sbom_occurrences.licenses (jsonb)
schema sbom_occurrences-licenses.json
anyOf: spdx_identifier OR name")]
D -.->|"separate READ path"| M["LicenseScanning::SbomScanner#report
→ PackageLicenses → LicenseScanning::Report"]
M -.-> N["MR license widget /
pipeline Licenses tab /
license compliance report"]
L -.-> O["dependency list
(GraphQL LicenseType, REST)"]
[!note] An expression is persisted under the
nameattribute, not a dedicated field.map_fromsetsname ||= expressionbecausesbom_occurrences.licensesrequiresspdx_identifierorname(neverexpressionalone) and the GraphQLLicenseType#nameis non-null. When only an expression is present,nameandexpressionhold the same string.
Parsers::Sbom::Cyclonedx#parse_components iterates the
components; Parsers::Sbom::Component#licenses maps each licenses[] entry through
License::Common.parse.License::Common#parse checks expression first (the
CycloneDX oneOf: an entry is either {expression} or {license: {id, name}}). An
expression builds Reports::Sbom::License(expression:); otherwise it reads license['id'],
license['name'], and url. check_license_name! moves a name into id when the name is
a valid SPDX identifier, a workaround for a Trivy 0.65.0 bug.Reports::Sbom::License carrying
spdx_identifier, name, url, and expression as plain attributes. No SPDX resolution
happens at parse time.Tasks::IngestOccurrences sets licenses: from
Sbom::Ingestion::LicensesFetcher#fetch, gated by
security_setting.license_scanning_for_cyclonedx_enabled.Gitlab::LicenseScanning::PackageLicenses
(pm_packages, pm_licenses), and to a single UNKNOWN_LICENSE when nothing is usable.PackageLicenses, usable_licenses drops
expression-only licenses, and include_expression? adds the expression key only when
cyclonedx_license_expression_ingestion is enabled for the project. With the flag off, the
behavior matches the previous ID and name only behavior.map_from keeps spdx_identifier, name, url, and
expression, and sets name ||= expression, because the JSON schema and the non-null GraphQL
LicenseType#name require a name and expression-only rows have none. The result is written to
the sbom_occurrences.licenses jsonb column, validated by sbom_occurrences-licenses.json
(anyOf requires spdx_identifier or name, never an expression alone).LicenseScanning::SbomScanner#report
runs PackageLicenses over the latest SBOM pipeline and builds a LicenseScanning::Report
consumed by the merge request license widget, the pipeline Licenses tab, and the license
compliance report. The stored sbom_occurrences.licenses feeds the dependency list (GraphQL
and REST).id is the primary key and drives the PMDB name and URL
lookup; name falls back to the expression string when only an expression is present;
expression is persisted only under the flag and is parsed and evaluated later by
Gitlab::SPDX::ExpressionParser in the policy and compliance layer, not at ingest.