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.