feat(build): add SPDX 2.3 SBOM generation for builds (#26731)

This commit is contained in:
Ramon Roche
2026-03-31 17:06:51 -06:00
committed by GitHub
parent 1d80fc317e
commit b243398231
16 changed files with 1308 additions and 4 deletions
+1
View File
@@ -268,4 +268,5 @@ jobs:
files: |
artifacts/*.px4
artifacts/*.deb
artifacts/**/*.sbom.spdx.json
name: ${{ steps.upload-location.outputs.uploadlocation }}
+2
View File
@@ -46,6 +46,8 @@ jobs:
fetch-depth: 0
- name: Building [${{ matrix.check }}]
env:
PX4_SBOM_DISABLE: 1
run: |
cd "$GITHUB_WORKSPACE"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
+2 -2
View File
@@ -36,8 +36,8 @@ jobs:
px4io/px4-dev-ros-melodic:2021-09-08 \
bash -c '
git config --global --add safe.directory /workspace
make px4_sitl_default
make px4_sitl_default sitl_gazebo-classic
PX4_SBOM_DISABLE=1 make px4_sitl_default
PX4_SBOM_DISABLE=1 make px4_sitl_default sitl_gazebo-classic
./test/rostest_px4_run.sh \
mavros_posix_test_mission.test \
mission:=MC_mission_box \
+2 -2
View File
@@ -36,8 +36,8 @@ jobs:
px4io/px4-dev-ros-melodic:2021-09-08 \
bash -c '
git config --global --add safe.directory /workspace
make px4_sitl_default
make px4_sitl_default sitl_gazebo-classic
PX4_SBOM_DISABLE=1 make px4_sitl_default
PX4_SBOM_DISABLE=1 make px4_sitl_default sitl_gazebo-classic
./test/rostest_px4_run.sh \
mavros_posix_tests_offboard_posctl.test \
vehicle:=iris
@@ -110,6 +110,8 @@ jobs:
run: ccache -s
- name: Build PX4
env:
PX4_SBOM_DISABLE: 1
run: make px4_sitl_default
- name: ccache post-run px4/firmware
run: ccache -s
+42
View File
@@ -0,0 +1,42 @@
name: SBOM License Check
on:
push:
branches:
- 'main'
- 'release/**'
- 'stable'
paths:
- '.gitmodules'
- 'Tools/ci/license-overrides.yaml'
- 'Tools/ci/generate_sbom.py'
pull_request:
branches:
- '**'
paths:
- '.gitmodules'
- 'Tools/ci/license-overrides.yaml'
- 'Tools/ci/generate_sbom.py'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify-licenses:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
submodules: false
- name: Install PyYAML
run: pip install pyyaml --break-system-packages
- name: Verify submodule licenses
run: python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir .
+132
View File
@@ -0,0 +1,132 @@
name: SBOM Monthly Audit
on:
schedule:
# First Monday of each month at 09:00 UTC
- cron: '0 9 1-7 * 1'
workflow_dispatch:
inputs:
branch:
description: 'Branch to audit (leave empty for current)'
required: false
type: string
permissions:
contents: read
issues: write
jobs:
audit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.branch || github.ref }}
fetch-depth: 1
submodules: recursive
- name: Install PyYAML
run: pip install pyyaml --break-system-packages
- name: Run license verification
id: verify
continue-on-error: true
run: |
python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir . 2>&1 | tee /tmp/sbom-verify.txt
echo "exit_code=$?" >> "$GITHUB_OUTPUT"
- name: Check for issues
id: check
run: |
if grep -q "NOASSERTION" /tmp/sbom-verify.txt; then
echo "has_issues=true" >> "$GITHUB_OUTPUT"
# Extract NOASSERTION lines
grep "NOASSERTION" /tmp/sbom-verify.txt | grep -v "skipped" > /tmp/sbom-issues.txt || true
# Extract copyleft lines
sed -n '/Copyleft licenses detected/,/^$/p' /tmp/sbom-verify.txt > /tmp/sbom-copyleft.txt || true
else
echo "has_issues=false" >> "$GITHUB_OUTPUT"
fi
- name: Create issue if problems found
if: steps.check.outputs.has_issues == 'true'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const fullOutput = fs.readFileSync('/tmp/sbom-verify.txt', 'utf8');
let issueLines = '';
try {
issueLines = fs.readFileSync('/tmp/sbom-issues.txt', 'utf8');
} catch (e) {
issueLines = 'No specific NOASSERTION lines captured.';
}
let copyleftLines = '';
try {
copyleftLines = fs.readFileSync('/tmp/sbom-copyleft.txt', 'utf8');
} catch (e) {
copyleftLines = '';
}
const date = new Date().toISOString().split('T')[0];
const branch = '${{ inputs.branch || github.ref_name }}';
// Check for existing open issue to avoid duplicates
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
labels: 'sbom-audit',
state: 'open',
});
if (existing.data.length > 0) {
// Update existing issue with new findings
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: existing.data[0].number,
body: `## Monthly audit update (${date})\n\nIssues still present:\n\n\`\`\`\n${issueLines}\n\`\`\`\n${copyleftLines ? `\n### Copyleft warnings\n\`\`\`\n${copyleftLines}\n\`\`\`` : ''}`,
});
return;
}
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `chore(sbom): license audit found NOASSERTION entries on ${branch} (${date})`,
labels: ['sbom-audit'],
assignees: ['mrpollo'],
body: [
`## SBOM Monthly Audit -- ${branch} -- ${date}`,
'',
'The automated SBOM license audit found submodules with unresolved licenses.',
'',
'### NOASSERTION entries',
'',
'```',
issueLines,
'```',
'',
copyleftLines ? `### Copyleft warnings\n\n\`\`\`\n${copyleftLines}\n\`\`\`\n` : '',
'### How to fix',
'',
'1. Check the submodule repo for a LICENSE file',
'2. Add an override to `Tools/ci/license-overrides.yaml`',
'3. Run `python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir .` to confirm',
'',
'### Full output',
'',
'<details>',
'<summary>Click to expand</summary>',
'',
'```',
fullOutput,
'```',
'',
'</details>',
'',
'cc @mrpollo',
].join('\n'),
});
+1
View File
@@ -71,6 +71,7 @@ jobs:
- name: Build PX4
env:
PX4_CMAKE_BUILD_TYPE: ${{matrix.config.build_type}}
PX4_SBOM_DISABLE: 1
run: make px4_sitl_default
- name: Cache Post-Run [px4_sitl_default]
+1
View File
@@ -484,6 +484,7 @@ include(bloaty)
include(metadata)
include(package)
include(sbom)
# install python requirements using configured python
add_custom_target(install_python_requirements
+603
View File
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Inspect a PX4 SPDX SBOM file.
Usage:
inspect_sbom.py <sbom.spdx.json> # full summary
inspect_sbom.py <sbom.spdx.json> search <term> # search packages by name
inspect_sbom.py <sbom.spdx.json> ntia # NTIA minimum elements check
inspect_sbom.py <sbom.spdx.json> licenses # license summary
inspect_sbom.py <sbom.spdx.json> list <type> # list packages (Submodule|PyDep|Module|all)
"""
import json
import sys
from collections import Counter
from pathlib import Path
def load(path):
return json.loads(Path(path).read_text())
def pkg_type(pkg):
spdx_id = pkg["SPDXID"]
for prefix in ("Submodule", "PyDep", "Module", "Compiler", "PX4"):
if f"-{prefix}-" in spdx_id or spdx_id.startswith(f"SPDXRef-{prefix}"):
return prefix
return "Other"
def summary(doc):
print(f"spdxVersion: {doc['spdxVersion']}")
print(f"name: {doc['name']}")
print(f"namespace: {doc['documentNamespace']}")
print(f"created: {doc['creationInfo']['created']}")
print(f"creators: {', '.join(doc['creationInfo']['creators'])}")
print()
types = Counter(pkg_type(p) for p in doc["packages"])
print(f"Packages: {len(doc['packages'])}")
for t, c in types.most_common():
print(f" {t}: {c}")
print()
rc = Counter(r["relationshipType"] for r in doc["relationships"])
print(f"Relationships: {len(doc['relationships'])}")
for t, n in rc.most_common():
print(f" {t}: {n}")
print()
primary = doc["packages"][0]
print(f"Primary package:")
print(f" name: {primary['name']}")
print(f" version: {primary['versionInfo']}")
print(f" purpose: {primary.get('primaryPackagePurpose', 'N/A')}")
print(f" license: {primary['licenseDeclared']}")
print()
noassert = [
p["name"]
for p in doc["packages"]
if pkg_type(p) == "Submodule" and p["licenseDeclared"] == "NOASSERTION"
]
if noassert:
print(f"WARNING: {len(noassert)} submodules with NOASSERTION license:")
for n in noassert:
print(f" - {n}")
else:
print("All submodule licenses mapped")
print(f"\nFile size: {Path(sys.argv[1]).stat().st_size // 1024}KB")
def search(doc, term):
term = term.lower()
found = [p for p in doc["packages"] if term in p["name"].lower()]
if not found:
print(f"No packages matching '{term}'")
return
print(f"Found {len(found)} packages matching '{term}':\n")
for p in found:
print(json.dumps(p, indent=2))
print()
def ntia_check(doc):
required = ["SPDXID", "name", "versionInfo", "supplier", "downloadLocation"]
missing = []
for p in doc["packages"]:
for f in required:
if f not in p or p[f] in ("", None):
missing.append((p["name"], f))
if missing:
print(f"FAIL: {len(missing)} missing fields:")
for name, field in missing:
print(f" {name}: missing {field}")
else:
print(f"PASS: All {len(doc['packages'])} packages have required fields")
print(f"\nCreators: {doc['creationInfo']['creators']}")
print(f"Timestamp: {doc['creationInfo']['created']}")
rels = [r for r in doc["relationships"] if r["relationshipType"] == "DESCRIBES"]
print(f"DESCRIBES relationships: {len(rels)}")
return len(missing) == 0
def licenses(doc):
by_license = {}
for p in doc["packages"]:
lic = p.get("licenseDeclared", "NOASSERTION")
by_license.setdefault(lic, []).append(p["name"])
for lic in sorted(by_license.keys()):
names = by_license[lic]
print(f"\n{lic} ({len(names)}):")
for n in sorted(names):
print(f" {n}")
def list_packages(doc, filter_type):
filter_type = filter_type.lower()
for p in sorted(doc["packages"], key=lambda x: x["name"]):
t = pkg_type(p)
if filter_type != "all" and t.lower() != filter_type:
continue
lic = p.get("licenseDeclared", "?")
ver = p["versionInfo"][:20] if len(p["versionInfo"]) > 20 else p["versionInfo"]
print(f" {t:10s} {p['name']:50s} {ver:20s} {lic}")
def main():
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
doc = load(sys.argv[1])
cmd = sys.argv[2] if len(sys.argv) > 2 else "summary"
if cmd == "summary":
summary(doc)
elif cmd == "search":
if len(sys.argv) < 4:
print("Usage: inspect_sbom.py <file> search <term>")
sys.exit(1)
search(doc, sys.argv[3])
elif cmd == "ntia":
if not ntia_check(doc):
sys.exit(1)
elif cmd == "licenses":
licenses(doc)
elif cmd == "list":
filter_type = sys.argv[3] if len(sys.argv) > 3 else "all"
list_packages(doc, filter_type)
else:
print(f"Unknown command: {cmd}")
print(__doc__)
sys.exit(1)
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
# SPDX license overrides for submodules where auto-detection fails or is wrong.
# Each entry maps a submodule path to its SPDX license identifier and an
# optional comment explaining why the override exists.
#
# Run `python3 Tools/ci/generate_sbom.py --verify-licenses` to validate.
overrides:
src/modules/mavlink/mavlink:
license: "LGPL-3.0-only AND MIT"
comment: "Generator is LGPL-3.0; PX4 ships only MIT-licensed generated headers."
src/lib/cdrstream/cyclonedds:
license: "EPL-2.0 OR BSD-3-Clause"
comment: >-
Dual-licensed. PX4 elects BSD-3-Clause.
No board currently enables CONFIG_LIB_CDRSTREAM.
src/lib/cdrstream/rosidl:
license: "Apache-2.0"
src/lib/crypto/monocypher:
license: "BSD-2-Clause OR CC0-1.0"
comment: >-
Dual-licensed. LICENCE.md offers BSD-2-Clause with CC0-1.0 as
public domain fallback.
src/lib/crypto/libtomcrypt:
license: "Unlicense"
comment: "Public domain dedication. Functionally equivalent to Unlicense."
src/lib/crypto/libtommath:
license: "Unlicense"
comment: "Public domain dedication. Functionally equivalent to Unlicense."
platforms/nuttx/NuttX/nuttx:
license: "Apache-2.0"
comment: >-
Composite LICENSE (6652 lines) includes BSD/MIT/ISC sub-components.
Primary license is Apache-2.0. NOTICE file contains FAT LFN patent warnings.
platforms/nuttx/NuttX/apps:
license: "Apache-2.0"
boards/modalai/voxl2/libfc-sensor-api:
license: "NOASSERTION"
comment: >-
No LICENSE file in repo. README describes it as public interface
for proprietary sensor library.
boards/modalai/voxl2/src/lib/mpa/libmodal-json:
license: "LGPL-3.0-only"
comment: "LGPL-3.0 weak copyleft. Used via header includes in VOXL2 mpa library."
boards/modalai/voxl2/src/lib/mpa/libmodal-pipe:
license: "LGPL-3.0-only"
comment: "LGPL-3.0 weak copyleft. Used via header includes in VOXL2 mpa library."
+2
View File
@@ -29,6 +29,8 @@ for build_dir_path in build/*/ ; do
# Events
mkdir -p artifacts/$build_dir/events/
cp $build_dir_path/events/all_events.json.xz artifacts/$build_dir/events/ 2>/dev/null || true
# SBOM
cp $build_dir_path/*.sbom.spdx.json artifacts/$build_dir/ 2>/dev/null || true
ls -la artifacts/$build_dir
echo "----------"
done
+72
View File
@@ -0,0 +1,72 @@
############################################################################
#
# Copyright (c) 2026 PX4 Development Team. All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
# 3. Neither the name PX4 nor the names of its contributors may be
# used to endorse or promote products derived from this software
# without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.
#
############################################################################
# SBOM - SPDX 2.3 JSON Software Bill of Materials generation
option(GENERATE_SBOM "Generate SPDX 2.3 SBOM" ON)
if(DEFINED ENV{PX4_SBOM_DISABLE})
set(GENERATE_SBOM OFF)
endif()
if(GENERATE_SBOM)
# Write board-specific module list for the SBOM generator
set(sbom_module_list_file "${PX4_BINARY_DIR}/config_module_list.txt")
get_property(module_list GLOBAL PROPERTY PX4_MODULE_PATHS)
string(REPLACE ";" "\n" module_list_content "${module_list}")
file(GENERATE OUTPUT ${sbom_module_list_file} CONTENT "${module_list_content}\n")
set(sbom_output "${PX4_BINARY_DIR}/${PX4_CONFIG}.sbom.spdx.json")
add_custom_command(
OUTPUT ${sbom_output}
COMMAND ${PYTHON_EXECUTABLE} ${PX4_SOURCE_DIR}/Tools/ci/generate_sbom.py
--source-dir ${PX4_SOURCE_DIR}
--board ${PX4_CONFIG}
--modules-file ${sbom_module_list_file}
--compiler ${CMAKE_C_COMPILER}
--platform ${PX4_PLATFORM}
--output ${sbom_output}
DEPENDS
${PX4_SOURCE_DIR}/Tools/ci/generate_sbom.py
${PX4_SOURCE_DIR}/Tools/ci/license-overrides.yaml
${PX4_SOURCE_DIR}/.gitmodules
${PX4_SOURCE_DIR}/Tools/setup/requirements.txt
${sbom_module_list_file}
COMMENT "Generating SPDX SBOM for ${PX4_CONFIG}"
)
add_custom_target(sbom ALL DEPENDS ${sbom_output})
endif()
+1
View File
@@ -906,6 +906,7 @@
- [Translation](contribute/translation.md)
- [Terminology/Notation](contribute/notation.md)
- [Licenses](contribute/licenses.md)
- [SBOM](contribute/sbom.md)
- [Releases](releases/index.md)
- [Release Process](releases/release_process.md)
- [main (alpha)](releases/main.md)
+226
View File
@@ -0,0 +1,226 @@
# Software Bill of Materials (SBOM)
PX4 generates a [Software Bill of Materials](https://ntia.gov/SBOM) for every firmware build in [SPDX 2.3](https://spdx.github.io/spdx-spec/v2.3/) JSON format.
## Why SBOM?
- **Regulatory compliance**: The EU Cyber Resilience Act (CRA) requires SBOMs for products with digital elements (reporting obligations begin in September 2026).
- **Supply chain transparency**: SBOMs enumerate every component compiled into firmware, enabling users and integrators to audit dependencies.
- **NTIA minimum elements**: Each SBOM satisfies all seven [NTIA required fields](https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom): supplier, component name, version, unique identifier, dependency relationship, author, and timestamp.
## Format
PX4 uses SPDX 2.3 JSON.
SPDX is the Linux Foundation's own standard (ISO/IEC 5962), aligning with PX4's position as a Dronecode/LF project.
Zephyr RTOS also uses SPDX.
Each SBOM contains:
- **Primary package**: The PX4 firmware for a specific board target, marked with `primaryPackagePurpose: FIRMWARE`.
- **Git submodules**: All third-party libraries included via git submodules (~33 packages), with SPDX license identifiers and commit hashes.
- **Python build dependencies**: Packages from `Tools/setup/requirements.txt` marked as `BUILD_DEPENDENCY_OF` the firmware.
- **Board-specific modules**: Internal PX4 modules compiled for the target board.
- **Compiler**: The C compiler used for the build.
Typical SBOM size: 70-100 packages, ~500 lines, ~20 KB JSON.
## Generation
SBOMs are generated automatically as part of every CMake build.
The output file is:
```txt
build/<target>/<target>.sbom.spdx.json
```
For example:
```txt
build/px4_fmu-v6x_default/px4_fmu-v6x_default.sbom.spdx.json
```
The generator script is `Tools/ci/generate_sbom.py`.
It requires PyYAML (`pyyaml`) for loading license overrides.
### CMake Integration
The `sbom` CMake target is included in the default `ALL` target.
The relevant CMake module is `cmake/sbom.cmake`.
### Disabling SBOM Generation
Set the environment variable before building.
This is checked at CMake configure time, so a clean build or reconfigure is required:
```sh
PX4_SBOM_DISABLE=1 make px4_fmu-v6x_default
```
If the build directory already exists, force a reconfigure:
```sh
PX4_SBOM_DISABLE=1 cmake -B build/px4_fmu-v6x_default .
```
### Manual Generation
You can also run the generator directly:
```sh
python3 Tools/ci/generate_sbom.py \
--source-dir . \
--board px4_fmu-v6x_default \
--modules-file build/px4_fmu-v6x_default/config_module_list.txt \
--compiler arm-none-eabi-gcc \
--output build/px4_fmu-v6x_default/px4_fmu-v6x_default.sbom.spdx.json
```
## Artifacts
SBOMs are available in:
| Location | Path |
| --------------- | ---------------------------------------- |
| Build directory | `build/<target>/<target>.sbom.spdx.json` |
| GitHub Releases | Alongside `.px4` firmware files |
| S3 | Same directory as firmware artifacts |
## Validation
Validate an SBOM against the SPDX JSON schema:
```sh
python3 -c "
import json
doc = json.load(open('build/px4_sitl_default/px4_sitl_default.sbom.spdx.json'))
assert doc['spdxVersion'] == 'SPDX-2.3'
assert doc['dataLicense'] == 'CC0-1.0'
assert len(doc['packages']) > 0
print(f'Valid: {len(doc[\"packages\"])} packages')
"
```
For full schema validation, use the [SPDX online validator](https://tools.spdx.org/app/validate/) or the `spdx-tools` CLI.
## License Detection
Submodule licenses are identified through a combination of auto-detection and manual overrides.
### Auto-Detection
The generator reads the first 100 lines of each submodule's LICENSE or COPYING file
and matches keywords against known patterns.
Copyleft licenses (GPL, LGPL, AGPL) are checked before permissive ones
to prevent false positives.
Supported patterns include:
| SPDX Identifier | Matched Keywords |
| --------------- | ----------------------------------------------------- |
| GPL-3.0-only | "GNU GENERAL PUBLIC LICENSE", "Version 3" |
| GPL-2.0-only | "GNU GENERAL PUBLIC LICENSE", "Version 2" |
| LGPL-3.0-only | "GNU LESSER GENERAL PUBLIC LICENSE", "Version 3" |
| LGPL-2.1-only | "GNU Lesser General Public License", "Version 2.1" |
| AGPL-3.0-only | "GNU AFFERO GENERAL PUBLIC LICENSE", "Version 3" |
| Apache-2.0 | "Apache License", "Version 2.0" |
| MIT | "Permission is hereby granted" |
| BSD-3-Clause | "Redistribution and use", "Neither the name" |
| BSD-2-Clause | "Redistribution and use", "THIS SOFTWARE IS PROVIDED" |
| ISC | "Permission to use, copy, modify, and/or distribute" |
| EPL-2.0 | "Eclipse Public License", "2.0" |
| Unlicense | "The Unlicense", "unlicense.org" |
If no pattern matches, the license is set to `NOASSERTION`.
### Override File
When auto-detection fails or returns the wrong result,
add an entry to `Tools/ci/license-overrides.yaml`:
```yaml
overrides:
src/lib/crypto/libtomcrypt:
license: "Unlicense"
comment: "Public domain dedication. Functionally equivalent to Unlicense."
```
Each entry maps a submodule path to its correct SPDX license identifier.
The optional `comment` field is emitted as `licenseComments` in the SBOM,
providing context for auditors reviewing complex licensing situations
(dual licenses, composite LICENSE files, public domain dedications).
### Copyleft Guardrail
The `--verify-licenses` command flags submodules with copyleft licenses
(GPL, LGPL, AGPL) in a dedicated warning section.
This is informational only and does not cause a failure.
It helps maintainers track copyleft obligations when adding new submodules.
### Platform Filtering
Submodules under `platforms/nuttx/` are excluded from POSIX and QURT SBOMs.
The `--platform` argument (set automatically by CMake via `${PX4_PLATFORM}`)
controls which platform-specific submodules are included.
This ensures SITL builds do not list NuttX RTOS packages.
### Verification
Run the verify command to check detection for all submodules:
```sh
python3 Tools/ci/generate_sbom.py --verify-licenses --source-dir .
```
This prints each submodule with its detected license, any override, and the final value.
It exits non-zero if any checked-out submodule resolves to `NOASSERTION` without an override.
Copyleft warnings are printed after the main table.
### Adding a New Submodule
1. Add the submodule normally.
2. Run `--verify-licenses` to confirm the license is detected.
3. If detection fails, add an override to `Tools/ci/license-overrides.yaml`.
4. If the license is not in the SPDX list, use `LicenseRef-<name>`.
### EU CRA Compliance
The EU Cyber Resilience Act requires SBOMs for products with digital elements.
The goal is zero `NOASSERTION` licenses in shipped firmware SBOMs.
Every submodule should have either a detected or overridden license.
The `--verify-licenses` check enforces this in CI.
## What's in an SBOM
This section is for integrators, compliance teams, and anyone reviewing SBOM artifacts.
### Where to Find SBOMs
| Location | Path |
| --------------- | ---------------------------------------- |
| Build directory | `build/<target>/<target>.sbom.spdx.json` |
| GitHub Releases | Alongside `.px4` firmware files |
| S3 | Same directory as firmware artifacts |
### Reading the JSON
Each SBOM is a single JSON document following SPDX 2.3.
Key fields:
- **`packages`**: Array of all components. Each has `name`, `versionInfo`, `licenseConcluded`, and `SPDXID`.
- **`relationships`**: How packages relate. `CONTAINS` means a submodule is compiled into firmware. `BUILD_DEPENDENCY_OF` means a tool used only during build.
- **`licenseConcluded`**: The SPDX license identifier determined for that package.
- **`licenseComments`**: Free-text explanation for complex cases (dual licenses, composite files, public domain).
- **`externalRefs`**: Package URLs (purls) linking to GitHub repos or PyPI.
### Understanding NOASSERTION
`NOASSERTION` means no license could be determined.
For submodules, this happens when:
- The submodule is not checked out (common in CI shallow clones).
- No LICENSE/COPYING file exists.
- The LICENSE file does not match any known pattern and no override is configured.
For shipped firmware, `NOASSERTION` should be resolved by adding an override.
For build-only dependencies (Python packages), `NOASSERTION` is acceptable
since these are not compiled into the firmware binary.