diff --git a/.github/workflows/build_all_targets.yml b/.github/workflows/build_all_targets.yml
index 1171d18e1b..92a73faaa6 100644
--- a/.github/workflows/build_all_targets.yml
+++ b/.github/workflows/build_all_targets.yml
@@ -268,4 +268,5 @@ jobs:
files: |
artifacts/*.px4
artifacts/*.deb
+ artifacts/**/*.sbom.spdx.json
name: ${{ steps.upload-location.outputs.uploadlocation }}
diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index e43feee8b3..0e13b21a0c 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -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"
diff --git a/.github/workflows/mavros_mission_tests.yml b/.github/workflows/mavros_mission_tests.yml
index 0b1a84f7b3..29b854b4de 100644
--- a/.github/workflows/mavros_mission_tests.yml
+++ b/.github/workflows/mavros_mission_tests.yml
@@ -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 \
diff --git a/.github/workflows/mavros_offboard_tests.yml b/.github/workflows/mavros_offboard_tests.yml
index dd6b750812..e59f340752 100644
--- a/.github/workflows/mavros_offboard_tests.yml
+++ b/.github/workflows/mavros_offboard_tests.yml
@@ -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
diff --git a/.github/workflows/ros_integration_tests.yml b/.github/workflows/ros_integration_tests.yml
index 4549aa086f..a294e7fd52 100644
--- a/.github/workflows/ros_integration_tests.yml
+++ b/.github/workflows/ros_integration_tests.yml
@@ -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
diff --git a/.github/workflows/sbom_license_check.yml b/.github/workflows/sbom_license_check.yml
new file mode 100644
index 0000000000..a2fe3ecf9d
--- /dev/null
+++ b/.github/workflows/sbom_license_check.yml
@@ -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 .
diff --git a/.github/workflows/sbom_monthly_audit.yml b/.github/workflows/sbom_monthly_audit.yml
new file mode 100644
index 0000000000..793ae95835
--- /dev/null
+++ b/.github/workflows/sbom_monthly_audit.yml
@@ -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',
+ '',
+ '',
+ 'Click to expand
',
+ '',
+ '```',
+ fullOutput,
+ '```',
+ '',
+ ' ',
+ '',
+ 'cc @mrpollo',
+ ].join('\n'),
+ });
diff --git a/.github/workflows/sitl_tests.yml b/.github/workflows/sitl_tests.yml
index c114b05cb3..d22951e277 100644
--- a/.github/workflows/sitl_tests.yml
+++ b/.github/workflows/sitl_tests.yml
@@ -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]
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 7f4ff17ac4..dac61c8f98 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -484,6 +484,7 @@ include(bloaty)
include(metadata)
include(package)
+include(sbom)
# install python requirements using configured python
add_custom_target(install_python_requirements
diff --git a/Tools/ci/generate_sbom.py b/Tools/ci/generate_sbom.py
new file mode 100755
index 0000000000..288f089983
--- /dev/null
+++ b/Tools/ci/generate_sbom.py
@@ -0,0 +1,603 @@
+#!/usr/bin/env python3
+"""Generate SPDX 2.3 JSON SBOM for a PX4 firmware build.
+
+Produces one SBOM per board target containing:
+- PX4 firmware as the primary package
+- Git submodules as CONTAINS dependencies
+- Python build requirements as BUILD_DEPENDENCY_OF packages
+- Board-specific modules as CONTAINS packages
+
+Requires PyYAML (pyyaml) for loading license overrides.
+"""
+import argparse
+import configparser
+import json
+import re
+import subprocess
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+import yaml
+
+# Ordered most-specific first: all keywords must appear for a match.
+LICENSE_PATTERNS = [
+ # Copyleft licenses first (more specific keywords prevent false matches)
+ ("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"]),
+ # Permissive licenses
+ ("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"]),
+]
+
+COPYLEFT_LICENSES = {
+ "GPL-2.0-only", "GPL-3.0-only",
+ "LGPL-2.1-only", "LGPL-3.0-only",
+ "AGPL-3.0-only",
+}
+
+def load_license_overrides(source_dir):
+ """Load license overrides and comments from YAML config file.
+
+ Returns (overrides, comments) dicts mapping submodule path to values.
+ Falls back to empty dicts if the file is missing.
+ """
+ yaml_path = source_dir / "Tools" / "ci" / "license-overrides.yaml"
+ if not yaml_path.exists():
+ return {}, {}
+
+ with open(yaml_path) as f:
+ data = yaml.safe_load(f)
+
+ overrides = {}
+ comments = {}
+ for path, entry in (data.get("overrides") or {}).items():
+ overrides[path] = entry["license"]
+ if "comment" in entry:
+ comments[path] = entry["comment"]
+
+ return overrides, comments
+
+LICENSE_FILENAMES = ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "LICENCE.md", "COPYING", "COPYING.md"]
+
+
+def detect_license(submodule_dir):
+ """Auto-detect SPDX license ID from LICENSE/COPYING file in a directory.
+
+ Reads the first 100 lines of the first license file found and matches
+ keywords against LICENSE_PATTERNS. Returns 'NOASSERTION' if no file
+ is found or no pattern matches.
+ """
+ for fname in LICENSE_FILENAMES:
+ license_file = submodule_dir / fname
+ if license_file.is_file():
+ try:
+ lines = license_file.read_text(errors="replace").splitlines()[:100]
+ text = "\n".join(lines)
+ except OSError:
+ continue
+
+ text_upper = text.upper()
+ for spdx_id_val, keywords in LICENSE_PATTERNS:
+ if all(kw.upper() in text_upper for kw in keywords):
+ return spdx_id_val
+
+ return "NOASSERTION"
+
+ return "NOASSERTION"
+
+
+def get_submodule_license(source_dir, sub_path, license_overrides):
+ """Return the SPDX license for a submodule: override > auto-detect."""
+ if sub_path in license_overrides:
+ return license_overrides[sub_path]
+ return detect_license(source_dir / sub_path)
+
+
+def spdx_id(name: str) -> str:
+ """Convert a name to a valid SPDX identifier (letters, digits, dots, hyphens)."""
+ return re.sub(r"[^a-zA-Z0-9.\-]", "-", name)
+
+
+def parse_gitmodules(source_dir):
+ """Parse .gitmodules and return list of {name, path, url}."""
+ gitmodules_path = source_dir / ".gitmodules"
+ if not gitmodules_path.exists():
+ return []
+
+ config = configparser.ConfigParser()
+ config.read(str(gitmodules_path))
+
+ submodules = []
+ for section in config.sections():
+ if section.startswith("submodule "):
+ name = section.split('"')[1] if '"' in section else section.split(" ", 1)[1]
+ path = config.get(section, "path", fallback="")
+ url = config.get(section, "url", fallback="")
+ submodules.append({"name": name, "path": path, "url": url})
+
+ return submodules
+
+
+def get_submodule_commits(source_dir):
+ """Get commit hashes for all submodules via git ls-tree -r (works without init)."""
+ try:
+ result = subprocess.run(
+ ["git", "ls-tree", "-r", "HEAD"],
+ cwd=str(source_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True,
+ )
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ return {}
+
+ commits = {}
+ for line in result.stdout.splitlines():
+ parts = line.split()
+ if len(parts) >= 4 and parts[1] == "commit":
+ commits[parts[3]] = parts[2]
+
+ return commits
+
+
+def get_git_info(source_dir: Path) -> dict:
+ """Get PX4 git version and hash."""
+ info = {"version": "unknown", "hash": "unknown"}
+ try:
+ result = subprocess.run(
+ ["git", "describe", "--always", "--tags", "--dirty"],
+ cwd=str(source_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True,
+ )
+ info["version"] = result.stdout.strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "HEAD"],
+ cwd=str(source_dir),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ check=True,
+ )
+ info["hash"] = result.stdout.strip()
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ pass
+ return info
+
+
+def parse_requirements(requirements_path):
+ """Parse pip requirements.txt into list of {name, version_spec}."""
+ if not requirements_path.exists():
+ return []
+
+ deps = []
+ for line in requirements_path.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or line.startswith("-"):
+ continue
+ # Split on version specifiers
+ match = re.match(r"^([a-zA-Z0-9_\-]+)(.*)?$", line)
+ if match:
+ deps.append({
+ "name": match.group(1),
+ "version_spec": match.group(2).strip() if match.group(2) else "",
+ })
+ return deps
+
+
+def read_module_list(modules_file, source_dir):
+ """Read board-specific module list from file.
+
+ Paths may be absolute; they are converted to relative paths under src/.
+ Duplicates are removed while preserving order.
+ """
+ if not modules_file or not modules_file.exists():
+ return []
+
+ seen = set()
+ modules = []
+ source_str = str(source_dir.resolve()) + "/"
+
+ for line in modules_file.read_text().splitlines():
+ path = line.strip()
+ if not path or path.startswith("#"):
+ continue
+ # Convert absolute path to relative
+ if path.startswith(source_str):
+ path = path[len(source_str):]
+ if path not in seen:
+ seen.add(path)
+ modules.append(path)
+
+ return modules
+
+
+def make_purl(pkg_type: str, namespace: str, name: str, version: str = "") -> str:
+ """Construct a Package URL (purl)."""
+ purl = f"pkg:{pkg_type}/{namespace}/{name}"
+ if version:
+ purl += f"@{version}"
+ return purl
+
+
+def extract_git_host_org_repo(url):
+ """Extract host type, org, and repo from a git URL.
+
+ Returns (host, org, repo) where host is 'github', 'gitlab', or ''.
+ """
+ match = re.search(r"github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$", url)
+ if match:
+ return "github", match.group(1), match.group(2)
+ match = re.search(r"gitlab\.com[:/](.+?)/([^/]+?)(?:\.git)?$", url)
+ if match:
+ return "gitlab", match.group(1), match.group(2)
+ return "", "", ""
+
+
+def generate_sbom(source_dir, board, modules_file, compiler, platform=""):
+ """Generate a complete SPDX 2.3 JSON document."""
+ license_overrides, license_comments = load_license_overrides(source_dir)
+ git_info = get_git_info(source_dir)
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ # Deterministic namespace using UUID5 from git hash + board
+ ns_seed = f"{git_info['hash']}:{board}"
+ doc_namespace = f"https://spdx.org/spdxdocs/{board}-{uuid.uuid5(uuid.NAMESPACE_URL, ns_seed)}"
+
+ doc = {
+ "spdxVersion": "SPDX-2.3",
+ "dataLicense": "CC0-1.0",
+ "SPDXID": "SPDXRef-DOCUMENT",
+ "name": f"PX4 Firmware SBOM for {board}",
+ "documentNamespace": doc_namespace,
+ "creationInfo": {
+ "created": timestamp,
+ "creators": [
+ "Tool: px4-generate-sbom",
+ "Organization: Dronecode Foundation",
+ ],
+ "licenseListVersion": "3.22",
+ },
+ "packages": [],
+ "relationships": [],
+ }
+
+ # Primary package: PX4 firmware
+ primary_spdx_id = f"SPDXRef-PX4-{spdx_id(board)}"
+ doc["packages"].append({
+ "SPDXID": primary_spdx_id,
+ "name": board,
+ "versionInfo": git_info["version"],
+ "packageFileName": f"{board}.px4",
+ "supplier": "Organization: Dronecode Foundation",
+ "downloadLocation": "https://github.com/PX4/PX4-Autopilot",
+ "filesAnalyzed": False,
+ "primaryPackagePurpose": "FIRMWARE",
+ "licenseConcluded": "BSD-3-Clause",
+ "licenseDeclared": "BSD-3-Clause",
+ "copyrightText": "Copyright (c) PX4 Development Team",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": make_purl(
+ "github", "PX4", "PX4-Autopilot", git_info["version"]
+ ),
+ }
+ ],
+ })
+
+ doc["relationships"].append({
+ "spdxElementId": "SPDXRef-DOCUMENT",
+ "relationshipType": "DESCRIBES",
+ "relatedSpdxElement": primary_spdx_id,
+ })
+
+ # Git submodules (filtered to those relevant to this board's modules)
+ submodules = parse_gitmodules(source_dir)
+ submodule_commits = get_submodule_commits(source_dir)
+ modules = read_module_list(modules_file, source_dir)
+
+ def submodule_is_relevant(sub_path):
+ """A submodule is relevant if any board module path overlaps with it."""
+ # NuttX platform submodules are only relevant for NuttX builds
+ if sub_path.startswith("platforms/nuttx/"):
+ return platform in ("nuttx", "")
+ if not modules:
+ return True # no module list means include all
+ # Other platform submodules are always relevant
+ if sub_path.startswith("platforms/"):
+ return True
+ for mod in modules:
+ # Module is under this submodule, or submodule is under a module
+ if mod.startswith(sub_path + "/") or sub_path.startswith(mod + "/"):
+ return True
+ return False
+
+ for sub in submodules:
+ if not submodule_is_relevant(sub["path"]):
+ continue
+ sub_path = sub["path"]
+ sub_path_id = sub_path.replace("/", "-")
+ sub_spdx_id = f"SPDXRef-Submodule-{spdx_id(sub_path_id)}"
+ commit = submodule_commits.get(sub_path, "unknown")
+ license_id = get_submodule_license(source_dir, sub_path, license_overrides)
+
+ host, org, repo = extract_git_host_org_repo(sub["url"])
+ download = sub["url"] if sub["url"] else "NOASSERTION"
+
+ # Use repo name from URL for human-readable name, fall back to last path component
+ display_name = repo if repo else sub_path.rsplit("/", 1)[-1]
+
+ pkg = {
+ "SPDXID": sub_spdx_id,
+ "name": display_name,
+ "versionInfo": commit,
+ "supplier": f"Organization: {org}" if org else "NOASSERTION",
+ "downloadLocation": download,
+ "filesAnalyzed": False,
+ "licenseConcluded": license_id,
+ "licenseDeclared": license_id,
+ "copyrightText": "NOASSERTION",
+ }
+
+ comment = license_comments.get(sub_path)
+ if comment:
+ pkg["licenseComments"] = comment
+
+ if host and org and repo:
+ pkg["externalRefs"] = [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": make_purl(host, org, repo, commit),
+ }
+ ]
+
+ doc["packages"].append(pkg)
+ doc["relationships"].append({
+ "spdxElementId": primary_spdx_id,
+ "relationshipType": "CONTAINS",
+ "relatedSpdxElement": sub_spdx_id,
+ })
+
+ # Python build dependencies
+ requirements_path = source_dir / "Tools" / "setup" / "requirements.txt"
+ py_deps = parse_requirements(requirements_path)
+
+ for dep in py_deps:
+ dep_name = dep["name"]
+ dep_spdx_id = f"SPDXRef-PyDep-{spdx_id(dep_name)}"
+ version_str = dep["version_spec"] if dep["version_spec"] else "NOASSERTION"
+
+ doc["packages"].append({
+ "SPDXID": dep_spdx_id,
+ "name": dep_name,
+ "versionInfo": version_str,
+ "supplier": "NOASSERTION",
+ "downloadLocation": f"https://pypi.org/project/{dep_name}/",
+ "filesAnalyzed": False,
+ "primaryPackagePurpose": "APPLICATION",
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ "externalRefs": [
+ {
+ "referenceCategory": "PACKAGE-MANAGER",
+ "referenceType": "purl",
+ "referenceLocator": f"pkg:pypi/{dep_name}",
+ }
+ ],
+ })
+ doc["relationships"].append({
+ "spdxElementId": dep_spdx_id,
+ "relationshipType": "BUILD_DEPENDENCY_OF",
+ "relatedSpdxElement": primary_spdx_id,
+ })
+
+ # Board-specific modules (already read above for submodule filtering)
+ for mod in modules:
+ mod_path_id = mod.replace("/", "-")
+ mod_spdx_id = f"SPDXRef-Module-{spdx_id(mod_path_id)}"
+
+ # Derive short name: strip leading src/ for readability
+ display_name = mod
+ if display_name.startswith("src/"):
+ display_name = display_name[4:]
+
+ doc["packages"].append({
+ "SPDXID": mod_spdx_id,
+ "name": display_name,
+ "versionInfo": git_info["version"],
+ "supplier": "Organization: Dronecode Foundation",
+ "downloadLocation": "https://github.com/PX4/PX4-Autopilot",
+ "filesAnalyzed": False,
+ "licenseConcluded": "BSD-3-Clause",
+ "licenseDeclared": "BSD-3-Clause",
+ "copyrightText": "NOASSERTION",
+ })
+ doc["relationships"].append({
+ "spdxElementId": primary_spdx_id,
+ "relationshipType": "CONTAINS",
+ "relatedSpdxElement": mod_spdx_id,
+ })
+
+ # Compiler as a build tool
+ if compiler:
+ compiler_spdx_id = f"SPDXRef-Compiler-{spdx_id(compiler)}"
+ doc["packages"].append({
+ "SPDXID": compiler_spdx_id,
+ "name": compiler,
+ "versionInfo": "NOASSERTION",
+ "supplier": "NOASSERTION",
+ "downloadLocation": "NOASSERTION",
+ "filesAnalyzed": False,
+ "primaryPackagePurpose": "APPLICATION",
+ "licenseConcluded": "NOASSERTION",
+ "licenseDeclared": "NOASSERTION",
+ "copyrightText": "NOASSERTION",
+ })
+ doc["relationships"].append({
+ "spdxElementId": compiler_spdx_id,
+ "relationshipType": "BUILD_TOOL_OF",
+ "relatedSpdxElement": primary_spdx_id,
+ })
+
+ return doc
+
+
+def verify_licenses(source_dir):
+ """Verify license detection for all submodules. Returns exit code."""
+ license_overrides, _ = load_license_overrides(source_dir)
+ submodules = parse_gitmodules(source_dir)
+ if not submodules:
+ print("No submodules found in .gitmodules")
+ return 1
+
+ has_noassertion = False
+ print(f"{'Submodule Path':<65} {'Detected':<16} {'Override':<16} {'Final'}")
+ print("-" * 115)
+
+ for sub in submodules:
+ sub_path = sub["path"]
+ sub_dir = source_dir / sub_path
+
+ checked_out = sub_dir.is_dir() and any(sub_dir.iterdir())
+ if not checked_out:
+ detected = "(not checked out)"
+ override = license_overrides.get(sub_path, "")
+ final = override if override else "NOASSERTION"
+ else:
+ detected = detect_license(sub_dir)
+ override = license_overrides.get(sub_path, "")
+ final = override if override else detected
+
+ if final == "NOASSERTION" and checked_out:
+ has_noassertion = True
+ marker = " <-- NOASSERTION"
+ elif final == "NOASSERTION" and not checked_out:
+ marker = " (skipped)"
+ else:
+ marker = ""
+
+ print(f"{sub_path:<65} {str(detected):<16} {str(override) if override else '':<16} {final}{marker}")
+
+ # Copyleft warning (informational, not a failure)
+ copyleft_found = []
+ for sub in submodules:
+ sub_path = sub["path"]
+ sub_dir = source_dir / sub_path
+ checked_out = sub_dir.is_dir() and any(sub_dir.iterdir())
+ override = license_overrides.get(sub_path, "")
+ if checked_out:
+ final_lic = override if override else detect_license(sub_dir)
+ else:
+ final_lic = override if override else "NOASSERTION"
+ for cl in COPYLEFT_LICENSES:
+ if cl in final_lic:
+ copyleft_found.append((sub_path, final_lic))
+ break
+
+ print()
+ if copyleft_found:
+ print("Copyleft licenses detected (informational):")
+ for path, lic in copyleft_found:
+ print(f" {path}: {lic}")
+ print()
+
+ if has_noassertion:
+ print("FAIL: Some submodules resolved to NOASSERTION. "
+ "Add an entry to Tools/ci/license-overrides.yaml or check the LICENSE file.")
+ return 1
+
+ print("OK: All submodules have a resolved license.")
+ return 0
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Generate SPDX 2.3 JSON SBOM for PX4 firmware"
+ )
+ parser.add_argument(
+ "--source-dir",
+ type=Path,
+ default=Path.cwd(),
+ help="PX4 source directory (default: cwd)",
+ )
+ parser.add_argument(
+ "--verify-licenses",
+ action="store_true",
+ help="Verify license detection for all submodules and exit",
+ )
+ parser.add_argument(
+ "--board",
+ default=None,
+ help="Board target name (e.g. px4_fmu-v5x_default)",
+ )
+ parser.add_argument(
+ "--modules-file",
+ type=Path,
+ default=None,
+ help="Path to config_module_list.txt",
+ )
+ parser.add_argument(
+ "--compiler",
+ default="",
+ help="Compiler identifier (e.g. arm-none-eabi-gcc)",
+ )
+ parser.add_argument(
+ "--platform",
+ default="",
+ help="PX4 platform (nuttx, posix, qurt). Filters platform-specific submodules.",
+ )
+ parser.add_argument(
+ "--output",
+ type=Path,
+ default=None,
+ help="Output SBOM file path",
+ )
+
+ args = parser.parse_args()
+
+ if args.verify_licenses:
+ raise SystemExit(verify_licenses(args.source_dir))
+
+ if not args.board:
+ parser.error("--board is required when not using --verify-licenses")
+ if not args.output:
+ parser.error("--output is required when not using --verify-licenses")
+
+ sbom = generate_sbom(
+ source_dir=args.source_dir,
+ board=args.board,
+ modules_file=args.modules_file,
+ compiler=args.compiler,
+ platform=args.platform,
+ )
+
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ with open(args.output, "w") as f:
+ json.dump(sbom, f, indent=2)
+ f.write("\n")
+
+ pkg_count = len(sbom["packages"])
+ print(f"SBOM generated: {args.output} ({pkg_count} packages)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tools/ci/inspect_sbom.py b/Tools/ci/inspect_sbom.py
new file mode 100755
index 0000000000..ac31660c80
--- /dev/null
+++ b/Tools/ci/inspect_sbom.py
@@ -0,0 +1,163 @@
+#!/usr/bin/env python3
+"""Inspect a PX4 SPDX SBOM file.
+
+Usage:
+ inspect_sbom.py # full summary
+ inspect_sbom.py search # search packages by name
+ inspect_sbom.py ntia # NTIA minimum elements check
+ inspect_sbom.py licenses # license summary
+ inspect_sbom.py list # 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 search ")
+ 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()
diff --git a/Tools/ci/license-overrides.yaml b/Tools/ci/license-overrides.yaml
new file mode 100644
index 0000000000..e08aeaeb7b
--- /dev/null
+++ b/Tools/ci/license-overrides.yaml
@@ -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."
diff --git a/Tools/ci/package_build_artifacts.sh b/Tools/ci/package_build_artifacts.sh
index cd7309a9e7..d371e25eba 100755
--- a/Tools/ci/package_build_artifacts.sh
+++ b/Tools/ci/package_build_artifacts.sh
@@ -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
diff --git a/cmake/sbom.cmake b/cmake/sbom.cmake
new file mode 100644
index 0000000000..c259748db0
--- /dev/null
+++ b/cmake/sbom.cmake
@@ -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()
diff --git a/docs/en/SUMMARY.md b/docs/en/SUMMARY.md
index 79c184173a..6b842e17fd 100644
--- a/docs/en/SUMMARY.md
+++ b/docs/en/SUMMARY.md
@@ -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)
diff --git a/docs/en/contribute/sbom.md b/docs/en/contribute/sbom.md
new file mode 100644
index 0000000000..3578bccda3
--- /dev/null
+++ b/docs/en/contribute/sbom.md
@@ -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//.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//.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-`.
+
+### 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//.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.