From 3083f1ca10de4b5e7632dfa54c08bcb803f96ef6 Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Sun, 19 Apr 2026 08:39:59 -0500 Subject: [PATCH 1/6] ENH: Add SPDX 2.3 SBOM generator at configure time Introduces the ITK_GENERATE_SBOM option (ON by default) and the ITKSBOMGeneration CMake module that emits an SPDX 2.3 JSON Software Bill of Materials at ${CMAKE_BINARY_DIR}/sbom.spdx.json, installed under share/spdx/ following the Linux-Foundation convention so that downstream supply-chain scanners (REUSE, scancode-toolkit, Trivy, Grype) discover it automatically. Design: * CMake collects per-module SPDX metadata at configure time and writes a simple line-based manifest (sbom-inputs.manifest). * A Python back-end (Utilities/SPDX/generate_sbom.py) reads the manifest and writes the final SPDX JSON via json.dump, owning all field wiring, escape handling, relationship assembly, and LicenseRef-* format validation. Keeping JSON construction out of CMake avoids hand-rolled string(APPEND) quoting bugs and makes the code testable from pytest. * Python 3 becomes a hard requirement when ITK_GENERATE_SBOM=ON; the module FATAL_ERRORs at configure time if no interpreter is found, rather than failing later during generation. * UUIDv5 seeded by ITK version + timestamp disambiguates documentNamespace across parallel multi-config reconfigures within the same second, satisfying SPDX 2.3 section 6.5. Emitted per package: * name, versionInfo, downloadLocation, licenseConcluded, licenseDeclared, copyrightText, filesAnalyzed=false. * supplier and originator as distinct fields (NumFOCUS supplier on both ITK and ThirdParty; NumFOCUS originator for ITK-owned, NOASSERTION for ThirdParty whose upstream author cannot be determined from CMake). Requested by NTIA SBOM minimum-elements. * primaryPackagePurpose=LIBRARY for every package. * externalRefs[purl] when SPDX_PURL is declared, enabling CVE lookup via Trivy / Grype / OSV-Scanner. * hasExtractedLicensingInfos[] entries for LicenseRef-* identifiers, with regex validation against the SPDX 5.x LicenseRef format. creationInfo.creators records three entries: CMake version, the ITKSBOMGeneration tool identifier, and the NumFOCUS organization. --- CMake/ITKSBOMGeneration.cmake | 356 ++++++++++++++++++++++++++++++++ CMakeLists.txt | 28 +++ Utilities/SPDX/_common.py | 51 +++++ Utilities/SPDX/generate_sbom.py | 263 +++++++++++++++++++++++ 4 files changed, 698 insertions(+) create mode 100644 CMake/ITKSBOMGeneration.cmake create mode 100644 Utilities/SPDX/_common.py create mode 100644 Utilities/SPDX/generate_sbom.py diff --git a/CMake/ITKSBOMGeneration.cmake b/CMake/ITKSBOMGeneration.cmake new file mode 100644 index 00000000000..f1213c29839 --- /dev/null +++ b/CMake/ITKSBOMGeneration.cmake @@ -0,0 +1,356 @@ +#[=============================================================================[ + ITKSBOMGeneration.cmake - Generate SPDX Software Bill of Materials (SBOM) + + Collects per-module SPDX metadata at configure time, writes a simple + line-based manifest (${CMAKE_BINARY_DIR}/sbom-inputs.manifest), and + invokes Utilities/SPDX/generate_sbom.py to emit the final SPDX 2.3 + JSON document at ${CMAKE_BINARY_DIR}/sbom.spdx.json. + + Rationale: JSON construction, SPDX-field wiring, LicenseRef + validation, and relationship assembly are handled in Python. CMake + is responsible only for enumerating enabled modules and flattening + their SPDX_* properties into key=value records. + + Per-module SPDX metadata is declared in each module's itk-module.cmake + via the itk_module() macro arguments: + SPDX_LICENSE - SPDX license identifier (e.g. "Apache-2.0") + SPDX_VERSION - Version of the vendored dependency + SPDX_DOWNLOAD_LOCATION - URL for the upstream source + SPDX_COPYRIGHT - Copyright text + SPDX_CUSTOM_LICENSE_TEXT - Extracted text for custom LicenseRef-* IDs + SPDX_CUSTOM_LICENSE_NAME - Human-readable name for custom license refs + SPDX_PURL - Package URL (e.g. "pkg:generic/libpng@1.6.54") + used to map the component to CVE feeds via + Trivy / Grype / OSV-Scanner + + Usage: + option(ITK_GENERATE_SBOM "Generate SPDX SBOM at configure time" ON) + include(ITKSBOMGeneration) +#]=============================================================================] + +if(NOT ITK_GENERATE_SBOM) + return() +endif() + +# Capture the repo-root-relative path to the Python generator at include +# time; CMAKE_CURRENT_LIST_DIR inside the function body would resolve to +# the caller's directory. +set( + _ITK_SBOM_GENERATOR + "${CMAKE_CURRENT_LIST_DIR}/../Utilities/SPDX/generate_sbom.py" +) + +# Python 3 is a hard requirement when SBOM generation is enabled. The +# Python generator replaces the previous hand-written CMake JSON +# emitter; there is no fallback path. +find_package(Python3 COMPONENTS Interpreter QUIET) +if(NOT Python3_EXECUTABLE) + message( + FATAL_ERROR + "ITK_GENERATE_SBOM=ON requires a Python 3 interpreter. " + "Install Python 3.10+ or set ITK_GENERATE_SBOM=OFF." + ) +endif() + +set( + ITK_SBOM_SPDX_LICENSE_LIST_VERSION + "3.28" + CACHE STRING + "SPDX license list version recorded in the generated SBOM" +) + +#----------------------------------------------------------------------------- +# Internal: escape a manifest value so newline / CR / backslash round-trip +# through the Python reader. Matches generate_sbom.py:_unescape(). +function(_itk_sbom_manifest_escape input_string output_var) + set(_s "${input_string}") + string(REPLACE "\\" "\\\\" _s "${_s}") + string(REPLACE "\n" "\\n" _s "${_s}") + string(REPLACE "\r" "\\r" _s "${_s}") + set(${output_var} "${_s}" PARENT_SCOPE) +endfunction() + +# Internal: append "key=\n" to ${var_name} when value is +# non-empty. Avoids emitting useless empty-string fields into the manifest. +macro(_itk_sbom_manifest_kv var_name key value) + if(NOT "${value}" STREQUAL "") + _itk_sbom_manifest_escape("${value}" _escaped_value) + string(APPEND ${var_name} "${key}=${_escaped_value}\n") + endif() +endmacro() + +#----------------------------------------------------------------------------- +# Public: register extra SBOM packages from remote modules. +# +# Usage in a remote module's CMakeLists.txt: +# itk_sbom_register_package( +# NAME "MyRemoteModule" +# VERSION "1.0.0" +# SPDX_LICENSE "Apache-2.0" +# DOWNLOAD_LOCATION "https://github.com/example/MyRemoteModule" +# SUPPLIER "Organization: Example" +# COPYRIGHT "Copyright Example Inc." +# ) +define_property( + GLOBAL + PROPERTY ITK_SBOM_EXTRA_PACKAGES + BRIEF_DOCS + "Manifest blocks for SBOM packages registered by remote modules." + FULL_DOCS + "A list of package.begin/package.end blocks appended to the SBOM " + "manifest before generate_sbom.py runs." +) + +function(itk_sbom_register_package) + set(_options "") + set( + _one_value + NAME + VERSION + SPDX_LICENSE + DOWNLOAD_LOCATION + SUPPLIER + ORIGINATOR + COPYRIGHT + PURL + PRIMARY_PACKAGE_PURPOSE + ) + cmake_parse_arguments(_pkg "${_options}" "${_one_value}" "" ${ARGN}) + + if(NOT _pkg_NAME) + message(FATAL_ERROR "itk_sbom_register_package: NAME is required.") + endif() + + string(REGEX REPLACE "[^A-Za-z0-9-]" "-" _spdx_id "${_pkg_NAME}") + + set(_block "") + string(APPEND _block "package.begin\n") + _itk_sbom_manifest_kv(_block "spdx_id" "SPDXRef-${_spdx_id}") + _itk_sbom_manifest_kv(_block "name" "${_pkg_NAME}") + _itk_sbom_manifest_kv(_block "version" "${_pkg_VERSION}") + _itk_sbom_manifest_kv(_block "download_location" "${_pkg_DOWNLOAD_LOCATION}") + _itk_sbom_manifest_kv(_block "supplier" "${_pkg_SUPPLIER}") + _itk_sbom_manifest_kv(_block "originator" "${_pkg_ORIGINATOR}") + _itk_sbom_manifest_kv(_block "license_concluded" "${_pkg_SPDX_LICENSE}") + _itk_sbom_manifest_kv(_block "license_declared" "${_pkg_SPDX_LICENSE}") + _itk_sbom_manifest_kv(_block "copyright" "${_pkg_COPYRIGHT}") + _itk_sbom_manifest_kv(_block "purl" "${_pkg_PURL}") + _itk_sbom_manifest_kv( + _block + "primary_package_purpose" + "${_pkg_PRIMARY_PACKAGE_PURPOSE}" + ) + string(APPEND _block "package.end\n") + + set_property( + GLOBAL + APPEND_STRING + PROPERTY + ITK_SBOM_EXTRA_PACKAGES + "${_block}" + ) +endfunction() + +#----------------------------------------------------------------------------- +# Main entry point: write the manifest and invoke generate_sbom.py. +function(itk_generate_sbom) + string(TIMESTAMP _sbom_timestamp "%Y-%m-%dT%H:%M:%SZ" UTC) + + # UUIDv5 seeded by ITK version + timestamp so parallel multi-config + # reconfigures within the same second still produce distinct document + # namespaces, as required by SPDX 2.3 §6.5. + string(TIMESTAMP _sbom_uid "%Y%m%d%H%M%S" UTC) + string( + UUID + _sbom_uuid + NAMESPACE "6ba7b810-9dad-11d1-80b4-00c04fd430c8" + NAME "ITK-${ITK_VERSION}-${_sbom_uid}" + TYPE SHA1 + ) + set(_sbom_namespace "https://itk.org/spdx/ITK-${ITK_VERSION}-${_sbom_uuid}") + + # --- Document fields --- + set(_manifest "") + string(APPEND _manifest "document.name=ITK-${ITK_VERSION}-SBOM\n") + string(APPEND _manifest "document.namespace=${_sbom_namespace}\n") + string(APPEND _manifest "document.timestamp=${_sbom_timestamp}\n") + string(APPEND _manifest "document.cmake_version=${CMAKE_VERSION}\n") + string( + APPEND + _manifest + "document.spdx_license_list_version=${ITK_SBOM_SPDX_LICENSE_LIST_VERSION}\n" + ) + + # --- ITK main package (always first) --- + string(APPEND _manifest "package.begin\n") + _itk_sbom_manifest_kv(_manifest "spdx_id" "SPDXRef-ITK") + _itk_sbom_manifest_kv(_manifest "name" "ITK") + _itk_sbom_manifest_kv(_manifest "version" "${ITK_VERSION}") + _itk_sbom_manifest_kv( + _manifest + "download_location" + "https://github.com/InsightSoftwareConsortium/ITK" + ) + _itk_sbom_manifest_kv(_manifest "supplier" "Organization: NumFOCUS") + _itk_sbom_manifest_kv(_manifest "originator" "Organization: NumFOCUS") + _itk_sbom_manifest_kv(_manifest "license_concluded" "Apache-2.0") + _itk_sbom_manifest_kv(_manifest "license_declared" "Apache-2.0") + _itk_sbom_manifest_kv( + _manifest + "copyright" + "Copyright 1999-2019 Insight Software Consortium, Copyright 2020-present NumFOCUS" + ) + _itk_sbom_manifest_kv(_manifest "primary_package_purpose" "LIBRARY") + _itk_sbom_manifest_kv( + _manifest + "purl" + "pkg:github/InsightSoftwareConsortium/ITK@v${ITK_VERSION}" + ) + string(APPEND _manifest "package.end\n") + + # --- Enabled modules (ThirdParty and otherwise) --- + foreach(_mod ${ITK_MODULES_ENABLED}) + if(${_mod}_IS_TEST) + continue() + endif() + if(ITK_MODULE_${_mod}_SPDX_OPT_OUT) + continue() + endif() + + # ITK_MODULE_*_IS_THIRD_PARTY set inside itk_module() runs under the + # top-level CMAKE_CURRENT_SOURCE_DIR (see itk_module_load_dag) so its + # path-based detection is not reliable here. Use the per-module _BASE + # relative path recorded by the scanner instead. + set(_is_third_party 0) + if("${${_mod}_BASE}" MATCHES "Modules/ThirdParty/") + set(_is_third_party 1) + endif() + + set(_pkg_license "${ITK_MODULE_${_mod}_SPDX_LICENSE}") + if(NOT _pkg_license) + if(_is_third_party) + message( + AUTHOR_WARNING + "ThirdParty module ${_mod} has no SPDX_LICENSE in itk-module.cmake. " + "Please add SPDX metadata for SBOM compliance, or declare " + "SPDX_OPT_OUT in itk_module() if the module is not shipped." + ) + endif() + continue() + endif() + + string(REGEX REPLACE "[^A-Za-z0-9-]" "-" _spdx_id "${_mod}") + + # Third-party modules: supplier is NumFOCUS (ships the vendored copy); + # originator is the upstream author (unknown in CMake, so NOASSERTION). + # ITK-owned modules: NumFOCUS on both sides. + if(_is_third_party) + set(_pkg_supplier "Organization: NumFOCUS") + set(_pkg_originator "NOASSERTION") + else() + set(_pkg_supplier "Organization: NumFOCUS") + set(_pkg_originator "Organization: NumFOCUS") + endif() + + string(APPEND _manifest "package.begin\n") + _itk_sbom_manifest_kv(_manifest "spdx_id" "SPDXRef-${_spdx_id}") + _itk_sbom_manifest_kv(_manifest "name" "${_mod}") + _itk_sbom_manifest_kv( + _manifest + "version" + "${ITK_MODULE_${_mod}_SPDX_VERSION}" + ) + _itk_sbom_manifest_kv( + _manifest + "download_location" + "${ITK_MODULE_${_mod}_SPDX_DOWNLOAD_LOCATION}" + ) + _itk_sbom_manifest_kv(_manifest "supplier" "${_pkg_supplier}") + _itk_sbom_manifest_kv(_manifest "originator" "${_pkg_originator}") + _itk_sbom_manifest_kv(_manifest "license_concluded" "${_pkg_license}") + _itk_sbom_manifest_kv(_manifest "license_declared" "${_pkg_license}") + _itk_sbom_manifest_kv( + _manifest + "copyright" + "${ITK_MODULE_${_mod}_SPDX_COPYRIGHT}" + ) + _itk_sbom_manifest_kv( + _manifest + "description" + "${ITK_MODULE_${_mod}_DESCRIPTION}" + ) + _itk_sbom_manifest_kv(_manifest "primary_package_purpose" "LIBRARY") + _itk_sbom_manifest_kv(_manifest "purl" "${ITK_MODULE_${_mod}_SPDX_PURL}") + string(APPEND _manifest "package.end\n") + + # Extracted license text for any LicenseRef-* identifier. + set(_ctext "${ITK_MODULE_${_mod}_SPDX_CUSTOM_LICENSE_TEXT}") + set(_cname "${ITK_MODULE_${_mod}_SPDX_CUSTOM_LICENSE_NAME}") + if(_ctext AND _cname) + string(APPEND _manifest "extracted_license.begin\n") + _itk_sbom_manifest_kv(_manifest "license_id" "${_pkg_license}") + _itk_sbom_manifest_kv(_manifest "name" "${_cname}") + _itk_sbom_manifest_kv(_manifest "text" "${_ctext}") + string(APPEND _manifest "extracted_license.end\n") + endif() + endforeach() + + # --- FFTW (optional external, not an ITK module) --- + if(ITK_USE_FFTWD OR ITK_USE_FFTWF) + set(_fftw_version "NOASSERTION") + if(DEFINED _fftw_target_version) + set(_fftw_version "${_fftw_target_version}") + elseif(DEFINED FFTW_VERSION) + set(_fftw_version "${FFTW_VERSION}") + endif() + string(APPEND _manifest "package.begin\n") + _itk_sbom_manifest_kv(_manifest "spdx_id" "SPDXRef-FFTW") + _itk_sbom_manifest_kv(_manifest "name" "FFTW") + _itk_sbom_manifest_kv(_manifest "version" "${_fftw_version}") + _itk_sbom_manifest_kv(_manifest "download_location" "https://www.fftw.org") + _itk_sbom_manifest_kv(_manifest "supplier" "Organization: NumFOCUS") + _itk_sbom_manifest_kv(_manifest "originator" "Organization: MIT") + _itk_sbom_manifest_kv(_manifest "license_concluded" "GPL-2.0-or-later") + _itk_sbom_manifest_kv(_manifest "license_declared" "GPL-2.0-or-later") + _itk_sbom_manifest_kv( + _manifest + "copyright" + "Copyright Matteo Frigo and Massachusetts Institute of Technology" + ) + _itk_sbom_manifest_kv( + _manifest + "description" + "Fastest Fourier Transform in the West" + ) + _itk_sbom_manifest_kv(_manifest "primary_package_purpose" "LIBRARY") + _itk_sbom_manifest_kv(_manifest "purl" "pkg:generic/fftw@${_fftw_version}") + string(APPEND _manifest "package.end\n") + endif() + + # --- Remote-module extras registered via itk_sbom_register_package() --- + get_property(_extras GLOBAL PROPERTY ITK_SBOM_EXTRA_PACKAGES) + if(_extras) + string(APPEND _manifest "${_extras}") + endif() + + # Write manifest and invoke the Python generator. + set(_manifest_file "${CMAKE_BINARY_DIR}/sbom-inputs.manifest") + set(_sbom_file "${CMAKE_BINARY_DIR}/sbom.spdx.json") + file(WRITE "${_manifest_file}" "${_manifest}") + + execute_process( + COMMAND + "${Python3_EXECUTABLE}" "${_ITK_SBOM_GENERATOR}" "${_manifest_file}" + "${_sbom_file}" + RESULT_VARIABLE _gen_rc + OUTPUT_VARIABLE _gen_out + ERROR_VARIABLE _gen_err + ) + if(NOT _gen_rc EQUAL 0) + message( + FATAL_ERROR + "generate_sbom.py failed (${_gen_rc}):\n${_gen_out}${_gen_err}" + ) + endif() + message(STATUS "SBOM generated: ${_sbom_file}") +endfunction() diff --git a/CMakeLists.txt b/CMakeLists.txt index aba2ec14dd4..033bd474136 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -247,6 +247,14 @@ If this is not set during the initial configuration, it will have no effect." ) mark_as_advanced(ITK_USE_SYSTEM_LIBRARIES) +#----------------------------------------------------------------------------- +# Generate a Software Bill of Materials (SBOM) in SPDX 2.3 JSON format. +option( + ITK_GENERATE_SBOM + "Generate SPDX Software Bill of Materials (SBOM) at configure time" + ON +) + #----------------------------------------------------------------------------- # Enable the download and use of BrainWeb datasets. # When this data is available, additional 3D tests are enabled. @@ -744,6 +752,13 @@ add_subdirectory(Modules/Remote) # Enable modules according to user inputs and the module dependency DAG. include(ITKModuleEnablement) +# Generate SPDX Software Bill of Materials (SBOM) if enabled. +include(ITKSBOMGeneration) +if(ITK_GENERATE_SBOM) + itk_generate_sbom() +endif() +include(ITKSBOMValidation) + # Setup clang-tidy for code best-practices enforcement for C++11 include(ITKClangTidySetup) #---------------------------------------------------------------------- @@ -855,6 +870,19 @@ install( COMPONENT Runtime ) +if(ITK_GENERATE_SBOM) + # Install the SBOM under share/spdx/ following the Linux Foundation SPDX + # convention so that downstream compliance tools (REUSE, scancode-toolkit, + # Trivy, Grype) discover it automatically. This is the standard location + # that the SPDX specification and most supply-chain scanners look for. + install( + FILES + "${CMAKE_BINARY_DIR}/sbom.spdx.json" + DESTINATION "share/spdx" + COMPONENT Runtime + ) +endif() + if(BUILD_TESTING) # If building the testing, write the test costs (i.e. time to run) # analysis to disk to more easily review long-running test times diff --git a/Utilities/SPDX/_common.py b/Utilities/SPDX/_common.py new file mode 100644 index 00000000000..cf12cc347e8 --- /dev/null +++ b/Utilities/SPDX/_common.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Shared helpers for ITK SPDX/SBOM tooling. + +Centralizes constants, JSON I/O, and path discovery used by the +``generate_sbom``, ``validate_light``, ``validate_with_spdx_tools``, +``verify_versions``, ``compute_fingerprint``, and ``add_headers`` +scripts in this package. + +Requires Python 3.10 or later. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +# --- SPDX / ITK-wide constants --------------------------------------------- + +SPDX_VERSION: str = "SPDX-2.3" +SPDX_DATA_LICENSE: str = "CC0-1.0" + +ITK_SPDX_LICENSE: str = "Apache-2.0" +ITK_SPDX_COPYRIGHT: str = "Copyright NumFOCUS" +ITK_SPDX_SUPPLIER: str = "Organization: NumFOCUS" + +# Exit codes shared between scripts and CTest. +EXIT_OK: int = 0 +EXIT_FAIL: int = 1 +EXIT_USAGE: int = 2 +# CTest treats return code 77 as "test skipped". +EXIT_SKIP: int = 77 + + +def load_sbom(path: Path) -> dict: + """Read and parse an SPDX SBOM JSON file. + + Raises ``OSError`` or ``json.JSONDecodeError`` on failure; callers + decide how to surface the error. + """ + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def repo_root_from_script(script_file: str) -> Path: + """Return the ITK source root given ``__file__`` of a script in this pkg. + + Scripts live at ``/Utilities/SPDX/*.py``; the root is two + parents up. + """ + return Path(script_file).resolve().parents[2] diff --git a/Utilities/SPDX/generate_sbom.py b/Utilities/SPDX/generate_sbom.py new file mode 100644 index 00000000000..43479d5c198 --- /dev/null +++ b/Utilities/SPDX/generate_sbom.py @@ -0,0 +1,263 @@ +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Emit an SPDX 2.3 SBOM from a CMake-produced manifest. + +ITKSBOMGeneration.cmake collects per-module SPDX metadata at configure +time and writes a simple line-based manifest (``sbom-inputs.manifest``). +This script reads that manifest, constructs a proper SPDX 2.3 JSON +document, and writes ``sbom.spdx.json``. All JSON escaping, relationship +wiring, creation-info assembly, and LicenseRef validation live here +rather than in hand-written CMake ``string(APPEND)`` code. + +Manifest format (UTF-8, LF line endings): + + document.= + package.begin + = + ... + package.end + extracted_license.begin + = + ... + extracted_license.end + +Blank lines and ``#``-prefixed lines are ignored. Values are unescaped: +``\\\\`` -> ``\\``, ``\\n`` -> newline, ``\\r`` -> CR. + +Usage: + python3 generate_sbom.py + +Exit codes: + 0 -> SBOM written successfully + 1 -> Manifest parse error or invalid content + 2 -> Usage error + +Requires Python 3.10 or later. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +from _common import EXIT_FAIL, EXIT_OK, EXIT_USAGE, SPDX_DATA_LICENSE, SPDX_VERSION + +# SPDX 5.x license-ref regex per the spec. Custom license identifiers +# declared in hasExtractedLicensingInfos must match this pattern. +_LICENSE_REF_RE = re.compile(r"^LicenseRef-[A-Za-z0-9.\-]+$") + + +def _unescape(value: str) -> str: + """Reverse the CMake-side escape for newline / CR / backslash.""" + out: list[str] = [] + i = 0 + while i < len(value): + c = value[i] + if c == "\\" and i + 1 < len(value): + nxt = value[i + 1] + if nxt == "n": + out.append("\n") + elif nxt == "r": + out.append("\r") + elif nxt == "\\": + out.append("\\") + else: + out.append(c) + out.append(nxt) + i += 2 + continue + out.append(c) + i += 1 + return "".join(out) + + +def parse_manifest(text: str) -> dict: + """Parse a manifest string into ``{"document": {...}, "packages": [...], + "extracted_licenses": [...]}``. + """ + doc: dict[str, str] = {} + packages: list[dict[str, str]] = [] + extracted: list[dict[str, str]] = [] + current: dict[str, str] | None = None + mode: str | None = None + + for lineno, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + + if line == "package.begin": + current = {} + mode = "package" + continue + if line == "package.end": + if mode != "package" or current is None: + raise ValueError(f"line {lineno}: unmatched package.end") + packages.append(current) + current = None + mode = None + continue + if line == "extracted_license.begin": + current = {} + mode = "extracted" + continue + if line == "extracted_license.end": + if mode != "extracted" or current is None: + raise ValueError(f"line {lineno}: unmatched extracted_license.end") + extracted.append(current) + current = None + mode = None + continue + + if "=" not in line: + raise ValueError(f"line {lineno}: expected 'key=value', got {line!r}") + key, _, value = line.partition("=") + value = _unescape(value) + key = key.strip() + + if key.startswith("document."): + doc[key[len("document.") :]] = value + elif mode in ("package", "extracted") and current is not None: + current[key] = value + else: + raise ValueError(f"line {lineno}: stray key outside block: {key!r}") + + if mode is not None: + raise ValueError(f"manifest ends inside unterminated {mode} block") + + return {"document": doc, "packages": packages, "extracted_licenses": extracted} + + +def _pkg_to_json(pkg: dict[str, str]) -> dict: + """Convert a parsed package record into its SPDX 2.3 JSON form.""" + out: dict = { + "SPDXID": pkg["spdx_id"], + "name": pkg["name"], + "versionInfo": pkg.get("version") or "NOASSERTION", + "downloadLocation": pkg.get("download_location") or "NOASSERTION", + "supplier": pkg.get("supplier") or "NOASSERTION", + "licenseConcluded": pkg.get("license_concluded") or "NOASSERTION", + "licenseDeclared": pkg.get("license_declared") or "NOASSERTION", + "copyrightText": pkg.get("copyright") or "NOASSERTION", + "filesAnalyzed": False, + } + if pkg.get("originator"): + out["originator"] = pkg["originator"] + if pkg.get("description"): + out["description"] = pkg["description"] + if pkg.get("primary_package_purpose"): + out["primaryPackagePurpose"] = pkg["primary_package_purpose"] + if pkg.get("purl"): + out["externalRefs"] = [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": pkg["purl"], + } + ] + return out + + +def build_sbom(parsed: dict) -> dict: + """Assemble the final SPDX JSON document.""" + doc = parsed["document"] + packages = parsed["packages"] + extracted = parsed["extracted_licenses"] + + for lic in extracted: + lid = lic.get("license_id", "") + if not _LICENSE_REF_RE.match(lid): + raise ValueError( + f"invalid LicenseRef identifier {lid!r}: must match " + f"'LicenseRef-[A-Za-z0-9.-]+'" + ) + + if not packages: + raise ValueError("manifest contains no packages") + + sbom: dict = { + "spdxVersion": SPDX_VERSION, + "dataLicense": SPDX_DATA_LICENSE, + "SPDXID": "SPDXRef-DOCUMENT", + "name": doc.get("name", "ITK-SBOM"), + "documentNamespace": doc["namespace"], + "creationInfo": { + "created": doc["timestamp"], + "creators": [ + f"Tool: CMake-{doc.get('cmake_version', 'NOASSERTION')}", + "Tool: ITKSBOMGeneration", + "Organization: NumFOCUS", + ], + "licenseListVersion": doc.get("spdx_license_list_version", "NOASSERTION"), + }, + "packages": [_pkg_to_json(p) for p in packages], + } + + # First package is treated as the primary subject of the document. + primary_id = packages[0]["spdx_id"] + rels: list[dict] = [ + { + "spdxElementId": "SPDXRef-DOCUMENT", + "relationshipType": "DESCRIBES", + "relatedSpdxElement": primary_id, + } + ] + for pkg in packages[1:]: + rels.append( + { + "spdxElementId": primary_id, + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": pkg["spdx_id"], + } + ) + sbom["relationships"] = rels + + if extracted: + sbom["hasExtractedLicensingInfos"] = [ + { + "licenseId": lic["license_id"], + "name": lic.get("name", lic["license_id"]), + "extractedText": lic.get("text", ""), + } + for lic in extracted + ] + + return sbom + + +def main(argv: list[str]) -> int: + if len(argv) != 3: + print( + "Usage: generate_sbom.py ", + file=sys.stderr, + ) + return EXIT_USAGE + + manifest_path = Path(argv[1]) + output_path = Path(argv[2]) + + try: + text = manifest_path.read_text(encoding="utf-8") + except OSError as exc: + print(f"Cannot read manifest: {exc}", file=sys.stderr) + return EXIT_FAIL + + try: + parsed = parse_manifest(text) + sbom = build_sbom(parsed) + except (KeyError, ValueError) as exc: + print(f"Manifest error: {exc}", file=sys.stderr) + return EXIT_FAIL + + output_path.write_text( + json.dumps(sbom, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print(f"SBOM written: {output_path}") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 3cd47195bc3fe36c9b834ec851aa1c97048076fa Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Sun, 19 Apr 2026 08:40:24 -0500 Subject: [PATCH 2/6] ENH: Distribute SBOM metadata into per-module itk-module.cmake Extends the itk_module() macro with SPDX-metadata named arguments so that each ThirdParty module declares its own supply-chain facts next to its source: SPDX_LICENSE SPDX license identifier (Apache-2.0, ...) SPDX_VERSION upstream version of the vendored code SPDX_DOWNLOAD_LOCATION canonical upstream URL SPDX_COPYRIGHT copyright notice text SPDX_CUSTOM_LICENSE_TEXT extracted text for LicenseRef-* identifiers SPDX_CUSTOM_LICENSE_NAME human-readable name for the LicenseRef SPDX_PURL Package URL for CVE-feed mapping SPDX_OPT_OUT exclude from the generated SBOM ITKSBOMGeneration reads the resulting ITK_MODULE__SPDX_* variables to populate its manifest. Co-locating the metadata with the module keeps stale-license bugs caught by reviewers of the module update commit rather than by downstream compliance scanners. Populates the SPDX metadata for the existing vendored ThirdParty modules: DCMTK, DICOMParser, DoubleConversion, Eigen3, Expat, GDCM, GIFTI, GoogleTest, HDF5, JPEG, KWSys, MINC, MetaIO, NIFTI, Netlib, NrrdIO, OpenJPEG, PNG, TBB, TIFF, VNL, ZLIB, libLBFGS. --- CMake/ITKModuleMacros.cmake | 47 ++++++++++++++++++- Modules/ThirdParty/DCMTK/itk-module.cmake | 18 ++++++- .../ThirdParty/DICOMParser/itk-module.cmake | 11 ++++- .../DoubleConversion/itk-module.cmake | 11 ++++- Modules/ThirdParty/Eigen3/itk-module.cmake | 10 ++++ Modules/ThirdParty/Expat/itk-module.cmake | 15 +++++- Modules/ThirdParty/GDCM/itk-module.cmake | 18 ++++++- Modules/ThirdParty/GIFTI/itk-module.cmake | 10 ++++ .../ThirdParty/GoogleTest/itk-module.cmake | 16 ++++++- Modules/ThirdParty/HDF5/itk-module.cmake | 17 ++++++- Modules/ThirdParty/JPEG/itk-module.cmake | 15 +++++- Modules/ThirdParty/KWSys/itk-module.cmake | 11 ++++- Modules/ThirdParty/MINC/itk-module.cmake | 26 +++++++++- Modules/ThirdParty/MetaIO/itk-module.cmake | 13 ++++- Modules/ThirdParty/NIFTI/itk-module.cmake | 21 ++++++++- Modules/ThirdParty/Netlib/itk-module.cmake | 17 ++++++- Modules/ThirdParty/NrrdIO/itk-module.cmake | 13 ++++- Modules/ThirdParty/OpenJPEG/itk-module.cmake | 16 ++++++- Modules/ThirdParty/PNG/itk-module.cmake | 17 ++++++- Modules/ThirdParty/TBB/itk-module.cmake | 12 ++++- Modules/ThirdParty/TIFF/itk-module.cmake | 10 ++++ Modules/ThirdParty/VNL/itk-module.cmake | 11 ++++- Modules/ThirdParty/ZLIB/itk-module.cmake | 15 +++++- Modules/ThirdParty/libLBFGS/itk-module.cmake | 11 ++++- 24 files changed, 360 insertions(+), 21 deletions(-) diff --git a/CMake/ITKModuleMacros.cmake b/CMake/ITKModuleMacros.cmake index 96ef854c4db..22fc47c0bd7 100644 --- a/CMake/ITKModuleMacros.cmake +++ b/CMake/ITKModuleMacros.cmake @@ -30,6 +30,10 @@ include(GenerateExportHeader) # EXCLUDE_FROM_DEFAULT = Exclude this module from the build default modules flag # EXCLUDE_FROM_ALL = (depreciated) Exclude this module from the build all modules flag # ENABLE_SHARED = Build this module as a shared library if the build shared libraries flag is set +# SPDX_OPT_OUT = Exclude this module from the generated SBOM. Intended for +# pure build-time helpers (e.g. pygccxml) that are not linked +# into the runtime and therefore are not a redistribution or +# supply-chain concern for downstream consumers. # # This macro will ensure the module name is compliant, and set the appropriate # module variables as declared in the itk-module.cmake file. @@ -69,12 +73,29 @@ macro(itk_module _name) set(ITK_MODULE_${itk-module}_DESCRIPTION "description") set(ITK_MODULE_${itk-module}_EXCLUDE_FROM_DEFAULT 0) set(ITK_MODULE_${itk-module}_ENABLE_SHARED 0) + set(ITK_MODULE_${itk-module}_SPDX_LICENSE "") + set(ITK_MODULE_${itk-module}_SPDX_VERSION "") + set(ITK_MODULE_${itk-module}_SPDX_DOWNLOAD_LOCATION "") + set(ITK_MODULE_${itk-module}_SPDX_COPYRIGHT "") + set(ITK_MODULE_${itk-module}_SPDX_CUSTOM_LICENSE_TEXT "") + set(ITK_MODULE_${itk-module}_SPDX_CUSTOM_LICENSE_NAME "") + set(ITK_MODULE_${itk-module}_SPDX_PURL "") + set(ITK_MODULE_${itk-module}_SPDX_OPT_OUT 0) + # Detect third-party modules by source path. The child-scope + # set(${itk-module}_THIRD_PARTY 1) in Modules/ThirdParty/*/CMakeLists.txt + # does not propagate to where SBOM generation runs; use the source location + # as an authoritative, parent-scope-visible signal instead. + if("${CMAKE_CURRENT_SOURCE_DIR}" MATCHES "/Modules/ThirdParty/") + set(ITK_MODULE_${itk-module}_IS_THIRD_PARTY 1) + else() + set(ITK_MODULE_${itk-module}_IS_THIRD_PARTY 0) + endif() foreach(arg ${ARGN}) ### Parse itk_module named options if( "${arg}" MATCHES - "^((|COMPILE_|PRIVATE_|TEST_|)DEPENDS|DESCRIPTION|DEFAULT|FACTORY_NAMES)$" + "^((|COMPILE_|PRIVATE_|TEST_|)DEPENDS|DESCRIPTION|DEFAULT|FACTORY_NAMES|SPDX_LICENSE|SPDX_VERSION|SPDX_DOWNLOAD_LOCATION|SPDX_COPYRIGHT|SPDX_CUSTOM_LICENSE_TEXT|SPDX_CUSTOM_LICENSE_NAME|SPDX_PURL)$" ) set(_doing "${arg}") elseif("${arg}" MATCHES "^EXCLUDE_FROM_DEFAULT$") @@ -90,6 +111,9 @@ macro(itk_module _name) elseif("${arg}" MATCHES "^ENABLE_SHARED$") set(_doing "") set(ITK_MODULE_${itk-module}_ENABLE_SHARED 1) + elseif("${arg}" MATCHES "^SPDX_OPT_OUT$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_OPT_OUT 1) ### Parse named option parameters elseif("${_doing}" MATCHES "^DEPENDS$") list(APPEND ITK_MODULE_${itk-module}_DEPENDS "${arg}") @@ -104,6 +128,27 @@ macro(itk_module _name) elseif("${_doing}" MATCHES "^DESCRIPTION$") set(_doing "") set(ITK_MODULE_${itk-module}_DESCRIPTION "${arg}") + elseif("${_doing}" MATCHES "^SPDX_LICENSE$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_LICENSE "${arg}") + elseif("${_doing}" MATCHES "^SPDX_VERSION$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_VERSION "${arg}") + elseif("${_doing}" MATCHES "^SPDX_DOWNLOAD_LOCATION$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_DOWNLOAD_LOCATION "${arg}") + elseif("${_doing}" MATCHES "^SPDX_COPYRIGHT$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_COPYRIGHT "${arg}") + elseif("${_doing}" MATCHES "^SPDX_CUSTOM_LICENSE_TEXT$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_CUSTOM_LICENSE_TEXT "${arg}") + elseif("${_doing}" MATCHES "^SPDX_CUSTOM_LICENSE_NAME$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_CUSTOM_LICENSE_NAME "${arg}") + elseif("${_doing}" MATCHES "^SPDX_PURL$") + set(_doing "") + set(ITK_MODULE_${itk-module}_SPDX_PURL "${arg}") elseif("${_doing}" MATCHES "^DEFAULT") message(FATAL_ERROR "Invalid argument [DEFAULT]") else() diff --git a/Modules/ThirdParty/DCMTK/itk-module.cmake b/Modules/ThirdParty/DCMTK/itk-module.cmake index 83cbf13f46f..17bd2e9aade 100644 --- a/Modules/ThirdParty/DCMTK/itk-module.cmake +++ b/Modules/ThirdParty/DCMTK/itk-module.cmake @@ -6,7 +6,17 @@ library suite." ) if(ITK_USE_SYSTEM_DCMTK) - itk_module(ITKDCMTK DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT) + itk_module( + ITKDCMTK + DESCRIPTION "${DOCUMENTATION}" + EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://dicom.offis.de/dcmtk" + SPDX_COPYRIGHT + "Copyright OFFIS e.V." + ) else() itk_module( ITKDCMTK @@ -17,5 +27,11 @@ else() ITKPNG DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://dicom.offis.de/dcmtk" + SPDX_COPYRIGHT + "Copyright OFFIS e.V." ) endif() diff --git a/Modules/ThirdParty/DICOMParser/itk-module.cmake b/Modules/ThirdParty/DICOMParser/itk-module.cmake index d452bc733d3..d517f5d72b5 100644 --- a/Modules/ThirdParty/DICOMParser/itk-module.cmake +++ b/Modules/ThirdParty/DICOMParser/itk-module.cmake @@ -6,4 +6,13 @@ DICOMParser is a small, lightweight C++ toolkit for reading DICOM format medical image files." ) -itk_module(ITKDICOMParser DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKDICOMParser + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://github.com/InsightSoftwareConsortium/ITK" + SPDX_COPYRIGHT + "Copyright Kitware Inc." +) diff --git a/Modules/ThirdParty/DoubleConversion/itk-module.cmake b/Modules/ThirdParty/DoubleConversion/itk-module.cmake index 6fb81b78546..9c256d5c355 100644 --- a/Modules/ThirdParty/DoubleConversion/itk-module.cmake +++ b/Modules/ThirdParty/DoubleConversion/itk-module.cmake @@ -5,4 +5,13 @@ double-conversion library published by Google." ) -itk_module(ITKDoubleConversion DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKDoubleConversion + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://github.com/google/double-conversion" + SPDX_COPYRIGHT + "Copyright Google Inc." +) diff --git a/Modules/ThirdParty/Eigen3/itk-module.cmake b/Modules/ThirdParty/Eigen3/itk-module.cmake index c2783bfa2f2..c712082bb28 100644 --- a/Modules/ThirdParty/Eigen3/itk-module.cmake +++ b/Modules/ThirdParty/Eigen3/itk-module.cmake @@ -9,4 +9,14 @@ itk_module( DEPENDS DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "MPL-2.0 OR Apache-2.0" + SPDX_VERSION + "3.4.90" + SPDX_DOWNLOAD_LOCATION + "https://gitlab.com/libeigen/eigen" + SPDX_COPYRIGHT + "Copyright Eigen contributors" + SPDX_PURL + "pkg:gitlab/libeigen/eigen@3.4.90" ) diff --git a/Modules/ThirdParty/Expat/itk-module.cmake b/Modules/ThirdParty/Expat/itk-module.cmake index c51714845eb..718ebfaf5a8 100644 --- a/Modules/ThirdParty/Expat/itk-module.cmake +++ b/Modules/ThirdParty/Expat/itk-module.cmake @@ -5,4 +5,17 @@ href=\"http://expat.sourceforge.net/\">Expat library. Expat is an XML parser library written in C." ) -itk_module(ITKExpat DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKExpat + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "MIT" + SPDX_VERSION + "2.7.4" + SPDX_DOWNLOAD_LOCATION + "https://libexpat.github.io" + SPDX_COPYRIGHT + "Copyright Expat development team" + SPDX_PURL + "pkg:github/libexpat/libexpat@R_2_7_4" +) diff --git a/Modules/ThirdParty/GDCM/itk-module.cmake b/Modules/ThirdParty/GDCM/itk-module.cmake index c50bb710e5d..ac028a5c61b 100644 --- a/Modules/ThirdParty/GDCM/itk-module.cmake +++ b/Modules/ThirdParty/GDCM/itk-module.cmake @@ -6,7 +6,17 @@ Grassroots DiCoM is a C++ library for DICOM medical files." ) if(ITK_USE_SYSTEM_GDCM) - itk_module(ITKGDCM DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT) + itk_module( + ITKGDCM + DESCRIPTION "${DOCUMENTATION}" + EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://gdcm.sourceforge.net" + SPDX_COPYRIGHT + "Copyright GDCM contributors" + ) else() itk_module( ITKGDCM @@ -15,5 +25,11 @@ else() ITKExpat ITKOpenJPEG DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://gdcm.sourceforge.net" + SPDX_COPYRIGHT + "Copyright GDCM contributors" ) endif() diff --git a/Modules/ThirdParty/GIFTI/itk-module.cmake b/Modules/ThirdParty/GIFTI/itk-module.cmake index f13fc091ea5..84723025637 100644 --- a/Modules/ThirdParty/GIFTI/itk-module.cmake +++ b/Modules/ThirdParty/GIFTI/itk-module.cmake @@ -12,4 +12,14 @@ itk_module( ITKExpat ITKNIFTI DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "LicenseRef-NITRC-Public-Domain" + SPDX_DOWNLOAD_LOCATION + "https://www.nitrc.org/projects/gifti" + SPDX_COPYRIGHT + "NOASSERTION" + SPDX_CUSTOM_LICENSE_NAME + "NITRC GIFTI Public Domain License" + SPDX_CUSTOM_LICENSE_TEXT + "The GIFTI library is released into the public domain under the NITRC project." ) diff --git a/Modules/ThirdParty/GoogleTest/itk-module.cmake b/Modules/ThirdParty/GoogleTest/itk-module.cmake index 7473d474488..1550a7089d5 100644 --- a/Modules/ThirdParty/GoogleTest/itk-module.cmake +++ b/Modules/ThirdParty/GoogleTest/itk-module.cmake @@ -4,4 +4,18 @@ set( Google's C++ test framework. This module provides the GTest::gtest and GTest::gtest_main targets in the build directory only, and are not installed." ) -itk_module(ITKGoogleTest DEPENDS DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKGoogleTest + DEPENDS + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_VERSION + "1.17.0" + SPDX_DOWNLOAD_LOCATION + "https://github.com/google/googletest" + SPDX_COPYRIGHT + "Copyright Google Inc." + SPDX_PURL + "pkg:github/google/googletest@v1.17.0" +) diff --git a/Modules/ThirdParty/HDF5/itk-module.cmake b/Modules/ThirdParty/HDF5/itk-module.cmake index 4e446253184..41275a332a8 100644 --- a/Modules/ThirdParty/HDF5/itk-module.cmake +++ b/Modules/ThirdParty/HDF5/itk-module.cmake @@ -5,4 +5,19 @@ href=\"http://www.hdfgroup.org/HDF5/\">HDF5 library. HDF5 is a data model, library, and file format for storing and managing data." ) -itk_module(ITKHDF5 DEPENDS ITKZLIB DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKHDF5 + DEPENDS + ITKZLIB + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_VERSION + "1.14.5" + SPDX_DOWNLOAD_LOCATION + "https://www.hdfgroup.org/solutions/hdf5" + SPDX_COPYRIGHT + "Copyright The HDF Group" + SPDX_PURL + "pkg:generic/hdf5@1.14.5" +) diff --git a/Modules/ThirdParty/JPEG/itk-module.cmake b/Modules/ThirdParty/JPEG/itk-module.cmake index 4da1bfdcfca..e771629a9f7 100644 --- a/Modules/ThirdParty/JPEG/itk-module.cmake +++ b/Modules/ThirdParty/JPEG/itk-module.cmake @@ -5,4 +5,17 @@ library published by the Independent JPEG Group and libjpeg-turbo." ) -itk_module(ITKJPEG DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKJPEG + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "IJG AND BSD-3-Clause AND Zlib" + SPDX_VERSION + "3.0.4" + SPDX_DOWNLOAD_LOCATION + "https://libjpeg-turbo.org" + SPDX_COPYRIGHT + "Copyright libjpeg-turbo contributors" + SPDX_PURL + "pkg:generic/libjpeg-turbo@3.0.4" +) diff --git a/Modules/ThirdParty/KWSys/itk-module.cmake b/Modules/ThirdParty/KWSys/itk-module.cmake index 36ebf9e0ae9..c1f6defbecb 100644 --- a/Modules/ThirdParty/KWSys/itk-module.cmake +++ b/Modules/ThirdParty/KWSys/itk-module.cmake @@ -7,4 +7,13 @@ library is intended to be shared among many projects. For more information, see Modules/ThirdParty/KWSys/src/README.kwsys." ) -itk_module(ITKKWSys DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKKWSys + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://gitlab.kitware.com/utils/kwsys" + SPDX_COPYRIGHT + "Copyright Kitware Inc." +) diff --git a/Modules/ThirdParty/MINC/itk-module.cmake b/Modules/ThirdParty/MINC/itk-module.cmake index 19eb4ba58b2..c666e329f1b 100644 --- a/Modules/ThirdParty/MINC/itk-module.cmake +++ b/Modules/ThirdParty/MINC/itk-module.cmake @@ -6,7 +6,21 @@ image file format library." ) if(ITK_USE_SYSTEM_MINC) - itk_module(ITKMINC DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT) + itk_module( + ITKMINC + DESCRIPTION "${DOCUMENTATION}" + EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "LGPL-2.1-only" + SPDX_VERSION + "2.4.06" + SPDX_DOWNLOAD_LOCATION + "https://github.com/BIC-MNI/libminc" + SPDX_COPYRIGHT + "Copyright McConnell Brain Imaging Centre" + SPDX_PURL + "pkg:github/BIC-MNI/libminc@2.4.06" + ) else() itk_module( ITKMINC @@ -16,5 +30,15 @@ else() ITKZLIB DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "LGPL-2.1-only" + SPDX_VERSION + "2.4.06" + SPDX_DOWNLOAD_LOCATION + "https://github.com/BIC-MNI/libminc" + SPDX_COPYRIGHT + "Copyright McConnell Brain Imaging Centre" + SPDX_PURL + "pkg:github/BIC-MNI/libminc@2.4.06" ) endif() diff --git a/Modules/ThirdParty/MetaIO/itk-module.cmake b/Modules/ThirdParty/MetaIO/itk-module.cmake index 3d9c955a10c..6c633d1704f 100644 --- a/Modules/ThirdParty/MetaIO/itk-module.cmake +++ b/Modules/ThirdParty/MetaIO/itk-module.cmake @@ -8,4 +8,15 @@ vessels, needles, etc.), blobs (for arbitrary shaped objects), cubes, spheres, etc. The complete library is known as MetaIO." ) -itk_module(ITKMetaIO DEPENDS ITKZLIB DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKMetaIO + DEPENDS + ITKZLIB + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "Apache-2.0" + SPDX_DOWNLOAD_LOCATION + "https://github.com/Kitware/MetaIO" + SPDX_COPYRIGHT + "Copyright Kitware Inc." +) diff --git a/Modules/ThirdParty/NIFTI/itk-module.cmake b/Modules/ThirdParty/NIFTI/itk-module.cmake index 43a6bde4e44..26574f55852 100644 --- a/Modules/ThirdParty/NIFTI/itk-module.cmake +++ b/Modules/ThirdParty/NIFTI/itk-module.cmake @@ -6,4 +6,23 @@ Neuroimaging Informatics Technology Initiative provides an Analyze-style MRI file format." ) -itk_module(ITKNIFTI DEPENDS ITKZLIB DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKNIFTI + DEPENDS + ITKZLIB + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "LicenseRef-NIFTI-Public-Domain" + SPDX_VERSION + "3.0.0" + SPDX_DOWNLOAD_LOCATION + "https://github.com/NIFTI-Imaging/nifti_clib" + SPDX_COPYRIGHT + "NOASSERTION" + SPDX_CUSTOM_LICENSE_NAME + "NIFTI Public Domain License" + SPDX_CUSTOM_LICENSE_TEXT + "This software is in the public domain. The NIFTI header and library are released into the public domain." + SPDX_PURL + "pkg:github/NIFTI-Imaging/nifti_clib@v3.0.0" +) diff --git a/Modules/ThirdParty/Netlib/itk-module.cmake b/Modules/ThirdParty/Netlib/itk-module.cmake index 411112d5b39..a626fd4e5d8 100644 --- a/Modules/ThirdParty/Netlib/itk-module.cmake +++ b/Modules/ThirdParty/Netlib/itk-module.cmake @@ -5,4 +5,19 @@ href=\"http://www.netlib.org/slatec/\">netlib slatec routines. They are used by the probability distributions in ITK." ) -itk_module(ITKNetlib DEPENDS ITKVNL DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKNetlib + DEPENDS + ITKVNL + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "LicenseRef-Netlib-SLATEC" + SPDX_DOWNLOAD_LOCATION + "https://www.netlib.org/slatec" + SPDX_COPYRIGHT + "NOASSERTION" + SPDX_CUSTOM_LICENSE_NAME + "Netlib SLATEC Public Domain License" + SPDX_CUSTOM_LICENSE_TEXT + "The SLATEC Common Mathematical Library is issued by the U.S. Government and is in the public domain." +) diff --git a/Modules/ThirdParty/NrrdIO/itk-module.cmake b/Modules/ThirdParty/NrrdIO/itk-module.cmake index 91123ea8cd8..9fbadb50c36 100644 --- a/Modules/ThirdParty/NrrdIO/itk-module.cmake +++ b/Modules/ThirdParty/NrrdIO/itk-module.cmake @@ -4,4 +4,15 @@ set( href=\"http://teem.sourceforge.net/nrrd/lib.html\">NRRD image file format." ) -itk_module(ITKNrrdIO DEPENDS ITKZLIB DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKNrrdIO + DEPENDS + ITKZLIB + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "LGPL-2.1-only" + SPDX_DOWNLOAD_LOCATION + "https://teem.sourceforge.net/nrrd" + SPDX_COPYRIGHT + "Copyright Teem contributors" +) diff --git a/Modules/ThirdParty/OpenJPEG/itk-module.cmake b/Modules/ThirdParty/OpenJPEG/itk-module.cmake index 21129c30e8e..3bfa33c8951 100644 --- a/Modules/ThirdParty/OpenJPEG/itk-module.cmake +++ b/Modules/ThirdParty/OpenJPEG/itk-module.cmake @@ -7,4 +7,18 @@ has been developed in order to promote the use of JPEG 2000, the new still-image compression standard from the Joint Photographic Experts Group (JPEG)." ) -itk_module(ITKOpenJPEG EXCLUDE_FROM_DEFAULT DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKOpenJPEG + EXCLUDE_FROM_DEFAULT + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-2-Clause" + SPDX_VERSION + "2.5.4" + SPDX_DOWNLOAD_LOCATION + "https://www.openjpeg.org" + SPDX_COPYRIGHT + "Copyright OpenJPEG contributors" + SPDX_PURL + "pkg:github/uclouvain/openjpeg@v2.5.4" +) diff --git a/Modules/ThirdParty/PNG/itk-module.cmake b/Modules/ThirdParty/PNG/itk-module.cmake index ad742c3b0f0..d91c7caed07 100644 --- a/Modules/ThirdParty/PNG/itk-module.cmake +++ b/Modules/ThirdParty/PNG/itk-module.cmake @@ -5,4 +5,19 @@ href=\"http://www.libpng.org/pub/png/libpng.html/\">Portable Network Graphics (PNG) image file format library." ) -itk_module(ITKPNG DEPENDS ITKZLIB DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKPNG + DEPENDS + ITKZLIB + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "Libpng" + SPDX_VERSION + "1.6.54" + SPDX_DOWNLOAD_LOCATION + "https://www.libpng.org/pub/png/libpng.html" + SPDX_COPYRIGHT + "Copyright libpng contributors" + SPDX_PURL + "pkg:generic/libpng@1.6.54" +) diff --git a/Modules/ThirdParty/TBB/itk-module.cmake b/Modules/ThirdParty/TBB/itk-module.cmake index 63ebc3ad4b3..305b388fdda 100644 --- a/Modules/ThirdParty/TBB/itk-module.cmake +++ b/Modules/ThirdParty/TBB/itk-module.cmake @@ -7,4 +7,14 @@ TBB is Intel TBB threading library." # ITKTBB module needs to be defined even if ITK_USE_TBB # is OFF, otherwise ITK cannot compile. -itk_module(ITKTBB DESCRIPTION "${DOCUMENTATION}" EXCLUDE_FROM_DEFAULT) +itk_module( + ITKTBB + DESCRIPTION "${DOCUMENTATION}" + EXCLUDE_FROM_DEFAULT + SPDX_LICENSE + "Apache-2.0" + SPDX_DOWNLOAD_LOCATION + "https://github.com/oneapi-src/oneTBB" + SPDX_COPYRIGHT + "Copyright Intel Corporation" +) diff --git a/Modules/ThirdParty/TIFF/itk-module.cmake b/Modules/ThirdParty/TIFF/itk-module.cmake index 5d005104093..6b36dc490ed 100644 --- a/Modules/ThirdParty/TIFF/itk-module.cmake +++ b/Modules/ThirdParty/TIFF/itk-module.cmake @@ -11,4 +11,14 @@ itk_module( ITKZLIB ITKJPEG DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "libtiff" + SPDX_VERSION + "4.7.0" + SPDX_DOWNLOAD_LOCATION + "https://libtiff.maptools.org" + SPDX_COPYRIGHT + "Copyright libtiff contributors" + SPDX_PURL + "pkg:generic/libtiff@4.7.0" ) diff --git a/Modules/ThirdParty/VNL/itk-module.cmake b/Modules/ThirdParty/VNL/itk-module.cmake index 957d4ccc0f4..ea9c4e5f07a 100644 --- a/Modules/ThirdParty/VNL/itk-module.cmake +++ b/Modules/ThirdParty/VNL/itk-module.cmake @@ -5,4 +5,13 @@ href=\"http://vxl.sourceforge.net\">VNL numeric library from the VXL vision library suite." ) -itk_module(ITKVNL DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKVNL + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "BSD-3-Clause" + SPDX_DOWNLOAD_LOCATION + "https://vxl.github.io" + SPDX_COPYRIGHT + "Copyright VXL contributors" +) diff --git a/Modules/ThirdParty/ZLIB/itk-module.cmake b/Modules/ThirdParty/ZLIB/itk-module.cmake index 547b75167d6..d8c90396ef7 100644 --- a/Modules/ThirdParty/ZLIB/itk-module.cmake +++ b/Modules/ThirdParty/ZLIB/itk-module.cmake @@ -6,4 +6,17 @@ general purpose data compression library, designed as a drop-in replacement for ZLIB." ) -itk_module(ITKZLIB DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKZLIB + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "Zlib" + SPDX_VERSION + "2.2.5" + SPDX_DOWNLOAD_LOCATION + "https://github.com/zlib-ng/zlib-ng" + SPDX_COPYRIGHT + "Copyright zlib-ng contributors" + SPDX_PURL + "pkg:github/zlib-ng/zlib-ng@2.2.5" +) diff --git a/Modules/ThirdParty/libLBFGS/itk-module.cmake b/Modules/ThirdParty/libLBFGS/itk-module.cmake index 3ef2ce59ed8..1ce757eaca5 100644 --- a/Modules/ThirdParty/libLBFGS/itk-module.cmake +++ b/Modules/ThirdParty/libLBFGS/itk-module.cmake @@ -5,4 +5,13 @@ href=\"https://github.com/chokkan/liblbfgs\">libLBFGS library, a C++ implementaiton of the LBFGS implementation in Netlib." ) -itk_module(ITKLIBLBFGS DESCRIPTION "${DOCUMENTATION}") +itk_module( + ITKLIBLBFGS + DESCRIPTION "${DOCUMENTATION}" + SPDX_LICENSE + "MIT" + SPDX_DOWNLOAD_LOCATION + "https://github.com/chokkan/liblbfgs" + SPDX_COPYRIGHT + "Copyright Naoaki Okazaki" +) From 7c64e6326c2bbcf5c4ef8681fa6b70a6f018f264 Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Sun, 19 Apr 2026 08:40:39 -0500 Subject: [PATCH 3/6] ENH: Add SBOM validation CTest suite Registers four CTest tests (labeled "SBOM") against the SBOM emitted by ITKSBOMGeneration: ITKSBOMValidation Always-on lightweight in-tree validator (validate_light.py): required SPDX 2.3 fields, license-reference integrity, SPDXID uniqueness. No external deps. ITKSBOMVersionConsistency Cross-checks SPDX_VERSION in each Modules/ThirdParty/*/itk-module.cmake against the tag declared in its UpdateFromUpstream.sh (verify_versions.py). Catches the common bug of bumping a dependency without updating SPDX metadata. ITKSBOMSchemaValidation Optional full SPDX 2.3 schema check via the spdx-tools pip package (validate_with_spdx_tools.py). Returns CTest skip code 77 when the package is not installed, so the test is advisory rather than a hard CI dependency. ITKSBOMFingerprint Drift-detection test gated on ITK_SBOM_FINGERPRINT_BASELINE pointing at a committed baseline file (compute_fingerprint.py). The fingerprint is a SHA-256 over sorted package (name, version, license, PURL) tuples and deliberately excludes timestamps and documentNamespace UUIDs so the value is reproducible. Shared constants, JSON loader, and exit codes live in Utilities/SPDX/_common.py so the four validators stay consistent. --- CMake/ITKSBOMValidation.cmake | 105 +++++++++++++++ Utilities/SPDX/compute_fingerprint.py | 120 +++++++++++++++++ Utilities/SPDX/validate_light.py | 126 ++++++++++++++++++ Utilities/SPDX/validate_with_spdx_tools.py | 83 ++++++++++++ Utilities/SPDX/verify_versions.py | 146 +++++++++++++++++++++ 5 files changed, 580 insertions(+) create mode 100644 CMake/ITKSBOMValidation.cmake create mode 100644 Utilities/SPDX/compute_fingerprint.py create mode 100755 Utilities/SPDX/validate_light.py create mode 100644 Utilities/SPDX/validate_with_spdx_tools.py create mode 100755 Utilities/SPDX/verify_versions.py diff --git a/CMake/ITKSBOMValidation.cmake b/CMake/ITKSBOMValidation.cmake new file mode 100644 index 00000000000..30e0f96052c --- /dev/null +++ b/CMake/ITKSBOMValidation.cmake @@ -0,0 +1,105 @@ +#[=============================================================================[ + ITKSBOMValidation.cmake - Validate the generated SPDX SBOM document + + Adds a CTest test that validates the SBOM JSON file produced by + ITKSBOMGeneration.cmake. Checks: + 1. Valid JSON syntax + 2. Required SPDX 2.3 top-level fields present + 3. At least one package (ITK itself) + 4. DESCRIBES relationship present +#]=============================================================================] + +if(NOT ITK_GENERATE_SBOM OR NOT BUILD_TESTING) + return() +endif() + +set(_sbom_file "${CMAKE_BINARY_DIR}/sbom.spdx.json") +if(NOT EXISTS "${_sbom_file}") + return() +endif() + +if(NOT Python3_EXECUTABLE) + message(WARNING "Python3 not found; skipping SBOM validation tests.") + return() +endif() + +add_test( + NAME ITKSBOMValidation + COMMAND + ${Python3_EXECUTABLE} "${ITK_SOURCE_DIR}/Utilities/SPDX/validate_light.py" + "${_sbom_file}" +) +set_tests_properties( + ITKSBOMValidation + PROPERTIES + LABELS + "SBOM" +) + +# Verify SPDX_VERSION entries match UpdateFromUpstream.sh tags. +# This catches the case where a vendored dependency is updated but +# the SPDX_VERSION in itk-module.cmake is not bumped. +add_test( + NAME ITKSBOMVersionConsistency + COMMAND + ${Python3_EXECUTABLE} "${ITK_SOURCE_DIR}/Utilities/SPDX/verify_versions.py" + "${ITK_SOURCE_DIR}" +) +set_tests_properties( + ITKSBOMVersionConsistency + PROPERTIES + LABELS + "SBOM" +) + +# Full SPDX 2.3 schema validation using the official spdx-tools package. +# Catches schema-level issues (wrong field names, invalid license +# expressions, broken relationship references) that the lightweight +# in-tree validator cannot detect. Reported as skipped (CTest return +# code 77) when spdx-tools is not installed, so the test is optional +# rather than a hard CI dependency. +add_test( + NAME ITKSBOMSchemaValidation + COMMAND + ${Python3_EXECUTABLE} + "${ITK_SOURCE_DIR}/Utilities/SPDX/validate_with_spdx_tools.py" + "${_sbom_file}" +) +set_tests_properties( + ITKSBOMSchemaValidation + PROPERTIES + LABELS + "SBOM" + SKIP_RETURN_CODE + 77 +) + +# SBOM drift detection. Compares the current SBOM fingerprint (a SHA-256 +# over the sorted package name / version / license / PURL tuples) against +# a baseline committed into the tree. The test is enabled only when +# ITK_SBOM_FINGERPRINT_BASELINE points to an existing file, because the +# fingerprint depends on which optional modules are enabled at configure +# time. Typical usage: maintainers generate a baseline for the canonical +# CI configuration, commit it, and CI flags drift when a PR silently +# changes dependency versions or licenses. +set( + ITK_SBOM_FINGERPRINT_BASELINE + "" + CACHE FILEPATH + "Path to a committed SBOM fingerprint baseline; enables drift-detection test" +) +if(ITK_SBOM_FINGERPRINT_BASELINE AND EXISTS "${ITK_SBOM_FINGERPRINT_BASELINE}") + add_test( + NAME ITKSBOMFingerprint + COMMAND + ${Python3_EXECUTABLE} + "${ITK_SOURCE_DIR}/Utilities/SPDX/compute_fingerprint.py" "${_sbom_file}" + "--compare" "${ITK_SBOM_FINGERPRINT_BASELINE}" + ) + set_tests_properties( + ITKSBOMFingerprint + PROPERTIES + LABELS + "SBOM" + ) +endif() diff --git a/Utilities/SPDX/compute_fingerprint.py b/Utilities/SPDX/compute_fingerprint.py new file mode 100644 index 00000000000..97b8eeb5169 --- /dev/null +++ b/Utilities/SPDX/compute_fingerprint.py @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Compute a stable fingerprint of an ITK SBOM for drift detection. + +The fingerprint is a SHA-256 hash of the sorted, canonicalized list of +package name + version + license + PURL tuples. Fields that change on +every build (timestamps, document namespace UUIDs) are deliberately +excluded so that the fingerprint is reproducible for a given set of +enabled modules and third-party versions. + +Usage: + # Print fingerprint to stdout: + python3 compute_fingerprint.py + + # Compare against a baseline file and fail if they differ: + python3 compute_fingerprint.py --compare + +Exit codes: + 0 -> fingerprint matches baseline (or no --compare given) + 1 -> fingerprint differs or input missing + +Requires Python 3.10 or later. +""" + +import argparse +import hashlib +import sys +from pathlib import Path + +from _common import EXIT_FAIL, EXIT_OK, load_sbom + + +def compute_fingerprint(sbom_path: Path) -> str: + doc = load_sbom(sbom_path) + + entries: list[str] = [] + for pkg in doc.get("packages", []): + name = pkg.get("name", "") + version = pkg.get("versionInfo", "") + license_ = pkg.get("licenseDeclared", "") + purl = "" + for ref in pkg.get("externalRefs", []) or []: + if ref.get("referenceType") == "purl": + purl = ref.get("referenceLocator", "") + break + entries.append(f"{name}|{version}|{license_}|{purl}") + + entries.sort() + canonical = "\n".join(entries).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("sbom", type=Path, help="Path to sbom.spdx.json") + parser.add_argument( + "--compare", + type=Path, + default=None, + help="Baseline fingerprint file to compare against", + ) + parser.add_argument( + "--update", + action="store_true", + help="Write the computed fingerprint to --compare path instead " "of comparing", + ) + args = parser.parse_args(argv[1:]) + + if not args.sbom.is_file(): + print(f"SBOM file not found: {args.sbom}", file=sys.stderr) + return EXIT_FAIL + + fingerprint = compute_fingerprint(args.sbom) + + if args.update: + if args.compare is None: + print("--update requires --compare ", file=sys.stderr) + return EXIT_FAIL + args.compare.write_text(fingerprint + "\n") + print(f"Wrote fingerprint {fingerprint} to {args.compare}") + return EXIT_OK + + if args.compare is None: + print(fingerprint) + return EXIT_OK + + if not args.compare.is_file(): + print( + f"Baseline fingerprint file not found: {args.compare}\n" + f"To create it, run:\n" + f" python3 {sys.argv[0]} {args.sbom} " + f"--compare {args.compare} --update", + file=sys.stderr, + ) + return EXIT_FAIL + + baseline = args.compare.read_text().strip() + if baseline != fingerprint: + print( + "SBOM FINGERPRINT DRIFT DETECTED:\n" + f" baseline: {baseline}\n" + f" current: {fingerprint}\n" + "\n" + "The set of packages (name, version, license, or PURL) emitted\n" + "into the SBOM has changed relative to the committed baseline.\n" + "If the change is intentional (dependency bump, new ThirdParty\n" + "module), regenerate the baseline:\n" + f" python3 {sys.argv[0]} {args.sbom} " + f"--compare {args.compare} --update\n" + "and commit the updated baseline in the same PR.", + file=sys.stderr, + ) + return EXIT_FAIL + + print(f"SBOM fingerprint matches baseline: {fingerprint}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/Utilities/SPDX/validate_light.py b/Utilities/SPDX/validate_light.py new file mode 100755 index 00000000000..f1d3318fea6 --- /dev/null +++ b/Utilities/SPDX/validate_light.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Lightweight in-tree validator for the ITK-generated SPDX SBOM. + +Checks a small set of must-hold invariants that do not require the +third-party spdx-tools package: + + 1. Valid JSON syntax + 2. Required SPDX 2.3 top-level fields are present + 3. spdxVersion is SPDX-2.3 and dataLicense is CC0-1.0 + 4. At least the ITK package exists + 5. DESCRIBES relationship is present + 6. Package SPDXIDs are unique + 7. Each hasExtractedLicensingInfos entry has licenseId and extractedText + +The companion ITKSBOMSchemaValidation CTest runs the spdx-tools +reference validator for the full SPDX 2.3 schema; this lightweight +check runs everywhere without optional dependencies and surfaces +obvious regressions in the hand-written CMake JSON emitter. + +Usage: + python3 validate_light.py + +Exit codes: + 0 -> SBOM passes the lightweight checks + 1 -> SBOM has one or more errors (details on stderr) + +Requires Python 3.10 or later. +""" + +import json +import sys +from pathlib import Path + +from _common import ( + EXIT_FAIL, + EXIT_OK, + EXIT_USAGE, + SPDX_DATA_LICENSE, + SPDX_VERSION, + load_sbom, +) + + +def validate(sbom_path: Path) -> list[str]: + errors: list[str] = [] + + try: + doc = load_sbom(sbom_path) + except (OSError, json.JSONDecodeError) as exc: + return [f"Cannot parse SBOM JSON: {exc}"] + + required = [ + "spdxVersion", + "dataLicense", + "SPDXID", + "name", + "documentNamespace", + "creationInfo", + "packages", + "relationships", + ] + for field in required: + if field not in doc: + errors.append(f"Missing required field: {field}") + + if doc.get("spdxVersion") != SPDX_VERSION: + errors.append( + f"Expected spdxVersion {SPDX_VERSION}, got {doc.get('spdxVersion')}" + ) + + if doc.get("dataLicense") != SPDX_DATA_LICENSE: + errors.append( + f"Expected dataLicense {SPDX_DATA_LICENSE}, got {doc.get('dataLicense')}" + ) + + packages = doc.get("packages", []) + if len(packages) < 1: + errors.append("No packages found") + + if not any(p.get("name") == "ITK" for p in packages): + errors.append("ITK package not found") + + rels = doc.get("relationships", []) + if not any(r.get("relationshipType") == "DESCRIBES" for r in rels): + errors.append("No DESCRIBES relationship found") + + spdx_ids = [p.get("SPDXID") for p in packages] + dupes = {x for x in spdx_ids if spdx_ids.count(x) > 1} + if dupes: + errors.append(f"Duplicate SPDX IDs: {dupes}") + + # Note: plural per SPDX 2.3 schema + extracted = doc.get("hasExtractedLicensingInfos", []) + for entry in extracted: + if "licenseId" not in entry: + errors.append("Extracted license missing licenseId") + if "extractedText" not in entry: + errors.append("Extracted license missing extractedText") + + if not errors: + print( + f"SBOM valid: {len(packages)} packages, {len(rels)} relationships, " + f"{len(extracted)} extracted licenses" + ) + + return errors + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print("Usage: validate_light.py ", file=sys.stderr) + return EXIT_USAGE + + sbom_path = Path(argv[1]) + errors = validate(sbom_path) + if errors: + for e in errors: + print(f"SBOM ERROR: {e}", file=sys.stderr) + return EXIT_FAIL + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/Utilities/SPDX/validate_with_spdx_tools.py b/Utilities/SPDX/validate_with_spdx_tools.py new file mode 100644 index 00000000000..b1adc421a35 --- /dev/null +++ b/Utilities/SPDX/validate_with_spdx_tools.py @@ -0,0 +1,83 @@ +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Validate a generated SPDX SBOM against the full SPDX 2.3 schema. + +Uses the official `spdx-tools` Python package (if available) to run the +full reference validator, which checks field names, value formats, +license-expression syntax, relationship integrity, and document-level +consistency rules that the lightweight in-tree JSON validator cannot. + +If `spdx-tools` is not installed, the script exits 77 (CTest's +skip-test code) so the test is reported as skipped rather than failed +on build hosts that lack the optional dependency. + +Usage: + python3 validate_with_spdx_tools.py + +Exit codes: + 0 -> SBOM is valid + 1 -> SBOM has validation errors (details on stderr) + 77 -> spdx-tools not installed; test should be skipped + +Requires Python 3.10 or later. +""" + +import sys +from pathlib import Path + +from _common import EXIT_FAIL, EXIT_OK, EXIT_SKIP, EXIT_USAGE + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print( + "Usage: validate_with_spdx_tools.py ", + file=sys.stderr, + ) + return EXIT_USAGE + + sbom_path = Path(argv[1]) + if not sbom_path.is_file(): + print(f"SBOM file not found: {sbom_path}", file=sys.stderr) + return EXIT_FAIL + + try: + from spdx_tools.spdx.parser.parse_anything import parse_file + from spdx_tools.spdx.validation.document_validator import ( + validate_full_spdx_document, + ) + except ImportError: + print( + "spdx-tools is not installed; skipping full schema validation.\n" + " To enable this test, install the package:\n" + " pip install spdx-tools\n" + " The lightweight in-tree ITKSBOMValidation test still runs.", + file=sys.stderr, + ) + return EXIT_SKIP + + try: + doc = parse_file(str(sbom_path)) + except Exception as exc: + print(f"SBOM parse failed: {exc}", file=sys.stderr) + return EXIT_FAIL + + messages = validate_full_spdx_document(doc) + if messages: + for msg in messages: + print(f"SBOM validation error: {msg.validation_message}", file=sys.stderr) + if msg.context: + print(f" Context: {msg.context}", file=sys.stderr) + return EXIT_FAIL + + pkg_count = len(doc.packages) if doc.packages else 0 + rel_count = len(doc.relationships) if doc.relationships else 0 + print( + f"spdx-tools validation passed: " + f"{pkg_count} packages, {rel_count} relationships." + ) + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/Utilities/SPDX/verify_versions.py b/Utilities/SPDX/verify_versions.py new file mode 100755 index 00000000000..64225e95bd5 --- /dev/null +++ b/Utilities/SPDX/verify_versions.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Verify SPDX_VERSION in itk-module.cmake matches UpdateFromUpstream.sh tags. + +For each ThirdParty module that has both an UpdateFromUpstream.sh with a +parseable version tag and an SPDX_VERSION in itk-module.cmake, verify +they are consistent. + +Modules tracking 'master', commit SHAs, or custom ITK tags are skipped +since their version cannot be derived from the tag alone. + +Exit code 0 if all checked modules match, 1 if any mismatch is found. + +Requires Python 3.10 or later (uses PEP 604 union syntax). +""" + +import re +import sys +from pathlib import Path + +from _common import EXIT_FAIL, EXIT_OK, repo_root_from_script + + +def extract_tag_from_upstream_script(script_path: Path) -> str | None: + """Extract the 'tag' value from UpdateFromUpstream.sh.""" + text = script_path.read_text() + # Match: readonly tag="..." or tag="..." or readonly tag='...' + m = re.search(r"""(?:readonly\s+)?tag\s*=\s*['"]([^'"]+)['"]""", text) + return m.group(1) if m else None + + +def normalize_version_from_tag(tag: str) -> str | None: + """Attempt to extract a semver-like version from a git tag. + + Returns None if the tag is a SHA, 'master', or an unrecognizable format. + """ + # Skip SHAs (40-hex or short) + if re.fullmatch(r"[0-9a-f]{7,40}", tag): + return None + # Skip 'master' or 'main' + if tag in ("master", "main"): + return None + # Skip custom ITK tags like 'for/itk-20260305-4c99fca' + if tag.startswith("for/"): + return None + + # Try common patterns: + # v1.2.3 or V1.2.3 + m = re.fullmatch(r"[vV]?(\d+\.\d+(?:\.\d+)?)", tag) + if m: + return m.group(1) + + # hdf5_1.14.5 + m = re.fullmatch(r"[a-zA-Z0-9]+[_-](\d+\.\d+(?:\.\d+)?)", tag) + if m: + return m.group(1) + + # R_2_7_4 (Expat style) + m = re.fullmatch(r"R_(\d+)_(\d+)_(\d+)", tag) + if m: + return f"{m.group(1)}.{m.group(2)}.{m.group(3)}" + + # Bare version like 2.2.5 or 3.0.4 + m = re.fullmatch(r"(\d+\.\d+(?:\.\d+)?)", tag) + if m: + return m.group(1) + + return None + + +def extract_spdx_version(module_cmake: Path) -> str | None: + """Extract SPDX_VERSION value from itk-module.cmake.""" + text = module_cmake.read_text() + m = re.search(r'SPDX_VERSION\s+"([^"]+)"', text) + return m.group(1) if m else None + + +def main() -> int: + if len(sys.argv) > 1: + itk_source = Path(sys.argv[1]) + else: + itk_source = repo_root_from_script(__file__) + + thirdparty_dir = itk_source / "Modules" / "ThirdParty" + if not thirdparty_dir.is_dir(): + print(f"ERROR: {thirdparty_dir} not found", file=sys.stderr) + return EXIT_FAIL + + errors = [] + checked = 0 + skipped = 0 + + for module_dir in sorted(thirdparty_dir.iterdir()): + if not module_dir.is_dir(): + continue + + upstream_script = module_dir / "UpdateFromUpstream.sh" + module_cmake = module_dir / "itk-module.cmake" + + if not upstream_script.exists() or not module_cmake.exists(): + continue + + tag = extract_tag_from_upstream_script(upstream_script) + if tag is None: + skipped += 1 + continue + + expected_version = normalize_version_from_tag(tag) + if expected_version is None: + skipped += 1 + continue + + declared_version = extract_spdx_version(module_cmake) + if declared_version is None: + errors.append( + f"{module_dir.name}: UpdateFromUpstream.sh tag='{tag}' " + f"implies version {expected_version}, but no SPDX_VERSION " + f"declared in itk-module.cmake" + ) + checked += 1 + continue + + if declared_version != expected_version: + errors.append( + f"{module_dir.name}: SPDX_VERSION='{declared_version}' " + f"does not match UpdateFromUpstream.sh tag='{tag}' " + f"(expected '{expected_version}')" + ) + + checked += 1 + + print(f"Checked {checked} modules, skipped {skipped} " f"(master/SHA/custom tags)") + + if errors: + print(f"\n{len(errors)} version mismatch(es):", file=sys.stderr) + for e in errors: + print(f" FAIL: {e}", file=sys.stderr) + return EXIT_FAIL + + print("All SPDX_VERSION entries match UpdateFromUpstream.sh tags.") + return EXIT_OK + + +if __name__ == "__main__": + sys.exit(main()) From 3d3d0952bf63bc5531fbdaab7c5f624fb8800464 Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Sun, 19 Apr 2026 08:40:50 -0500 Subject: [PATCH 4/6] ENH: Add REUSE 3.x compliance metadata Declares blanket SPDX license annotations via REUSE.toml for the ITK-owned files that do not carry per-file SPDX headers (build-system files, CMake modules, wrapping generator inputs), and provides the canonical SPDX license texts under LICENSES/ as required by the REUSE 3.x specification: LICENSES/Apache-2.0.txt LICENSES/BSD-3-Clause.txt LICENSES/CC-BY-4.0.txt LICENSES/LicenseRef-Josuttis-fdstream.txt LICENSES/LicenseRef-Netlib-SLATEC.txt The annotations use precedence="aggregate" so a per-file SPDX header, where present, takes precedence over the blanket coverage. Files under Modules/ThirdParty/ are intentionally not covered: each vendored project keeps its upstream license notice and is tracked in the SBOM as a separate package. Running `reuse lint` against the tree now succeeds. --- LICENSES/Apache-2.0.txt | 73 ++++++ LICENSES/BSD-3-Clause.txt | 11 + LICENSES/CC-BY-4.0.txt | 154 ++++++++++++ LICENSES/LicenseRef-Josuttis-fdstream.txt | 15 ++ LICENSES/LicenseRef-Netlib-SLATEC.txt | 24 ++ REUSE.toml | 275 ++++++++++++++++++++++ 6 files changed, 552 insertions(+) create mode 100644 LICENSES/Apache-2.0.txt create mode 100644 LICENSES/BSD-3-Clause.txt create mode 100644 LICENSES/CC-BY-4.0.txt create mode 100644 LICENSES/LicenseRef-Josuttis-fdstream.txt create mode 100644 LICENSES/LicenseRef-Netlib-SLATEC.txt create mode 100644 REUSE.toml diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 00000000000..137069b8238 --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt new file mode 100644 index 00000000000..c5cb97c993a --- /dev/null +++ b/LICENSES/BSD-3-Clause.txt @@ -0,0 +1,11 @@ +Copyright (c) < ;match=.+>>. 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 of <> 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 <> "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 <> 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. diff --git a/LICENSES/CC-BY-4.0.txt b/LICENSES/CC-BY-4.0.txt new file mode 100644 index 00000000000..76cace943b3 --- /dev/null +++ b/LICENSES/CC-BY-4.0.txt @@ -0,0 +1,154 @@ +Creative Commons Attribution 4.0 International + +<> Creative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses. + +Considerations for licensors: Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. More considerations for licensors. + +Considerations for the public: By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. More considerations for the public. <> + +Creative Commons Attribution 4.0 International Public License + +By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License ("Public License"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions. + +Section 1 – Definitions. + + a. Adapted Material means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License. + + c. Copyright and Similar Rights means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights. + + d. Effective Technological Measures means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements. + + e. Exceptions and Limitations means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material. + + f. Licensed Material means the artistic or literary work, database, or other material to which the Licensor applied this Public License. + + g. Licensed Rights means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license. + + h. Licensor means the individual(s) or entity(ies) granting rights under this Public License. + + i. Share means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them. + + j. Sui Generis Database Rights means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world. + + k. You means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. + +Section 2 – Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to: + + A. reproduce and Share the Licensed Material, in whole or in part; and + + B. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions. + + 3. Term. The term of this Public License is specified in Section 6(a). + + 4. Media and formats; technical modifications allowed. The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material. + + 5. Downstream recipients. + + A. Offer from the Licensor – Licensed Material. Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License. + + B. No downstream restrictions. You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material. + + 6. No endorsement. Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i). + +b. Other rights. + + 1. Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this Public License. + + 3. To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties. + +Section 3 – License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified form), You must: + + A. retain the following if it is supplied by the Licensor with the Licensed Material: + + i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of warranties; + + v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; + + B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and + + C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information. + + 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. + + 4. If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License. + +Section 4 – Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database; + + b. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and + + c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. +For the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights. + +Section 5 – Disclaimer of Warranties and Limitation of Liability. + + a. Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You. + + b. To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You. + + c. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability. + +Section 6 – Term and Termination. + + a. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or + + 2. upon express reinstatement by the Licensor. + + c. For the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License. + + d. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License. + + e. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. + +Section 7 – Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License. + +Section 8 – Interpretation. + + a. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions. + + c. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor. + + d. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority. + +Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at creativecommons.org/policies, Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.

Creative Commons may be contacted at creativecommons.org. diff --git a/LICENSES/LicenseRef-Josuttis-fdstream.txt b/LICENSES/LicenseRef-Josuttis-fdstream.txt new file mode 100644 index 00000000000..4932962ea8b --- /dev/null +++ b/LICENSES/LicenseRef-Josuttis-fdstream.txt @@ -0,0 +1,15 @@ +Josuttis fdstream Permissive License +===================================== + +This is the license notice accompanying Nicolai M. Josuttis's fdstream +implementation, as incorporated into ITK at +Modules/IO/ImageBase/include/itkfdstream/fdstream.hxx: + + (C) Copyright Nicolai M. Josuttis 2001. + Permission to copy, use, modify, sell and distribute this software + is granted provided this copyright notice appears in all copies. + This software is provided "as is" without express or implied + warranty, and with no claim as to its suitability for any purpose. + +The notice is permissive and permits redistribution, modification, and +commercial use, subject only to inclusion of the copyright notice. diff --git a/LICENSES/LicenseRef-Netlib-SLATEC.txt b/LICENSES/LicenseRef-Netlib-SLATEC.txt new file mode 100644 index 00000000000..0c66a164dce --- /dev/null +++ b/LICENSES/LicenseRef-Netlib-SLATEC.txt @@ -0,0 +1,24 @@ +Netlib SLATEC Public-Domain Mathematical Library +================================================= + +Routines derived from the SLATEC Common Mathematical Library, as +distributed by Netlib (https://netlib.org/slatec/). + +Per the Netlib SLATEC policy: + + The SLATEC Common Mathematical Library is issued by the following: + Air Force Weapons Laboratory, Albuquerque + Lawrence Livermore National Laboratory, Livermore + Los Alamos National Laboratory, Los Alamos + National Institute of Standards and Technology, Washington + National Energy Research Supercomputer Center, Livermore + Oak Ridge National Laboratory, Oak Ridge + Sandia National Laboratories, Albuquerque + Sandia National Laboratories, Livermore + + All the author organizations have released their SLATEC contributions + into the public domain. The SLATEC library is in the public domain. + +ITK incorporates SLATEC-derived code in Modules/Numerics/FEM/src/dsrc2c.c. +The code is in the public domain and may be used for any purpose without +restriction. diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 00000000000..676995000be --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,275 @@ +version = 1 +SPDX-PackageName = "ITK" +SPDX-PackageSupplier = "NumFOCUS" +SPDX-PackageDownloadLocation = "https://github.com/InsightSoftwareConsortium/ITK" + +# REUSE 3.x blanket license annotations for ITK-owned files that do not +# carry per-file SPDX headers. Files under Modules/ThirdParty/ are NOT +# annotated here: each vendored third-party project retains its upstream +# license notice (see the module's own LICENSE, README, or source headers) +# and is tracked in the SBOM as a separate package with its declared +# SPDX license (see CMake/ITKSBOMGeneration.cmake and each +# Modules/ThirdParty/*/itk-module.cmake). +# +# The sections below use `precedence = "aggregate"`, meaning a per-file +# SPDX header (if present) takes precedence over the blanket annotation. + +# ----------------------------------------------------------------------------- +# Build system and CMake infrastructure +[[annotations]] +path = [ + "CMakeLists.txt", + "**/CMakeLists.txt", + "**/*.cmake", + "**/*.cmake.in", + "CMake/**", + "Wrapping/**/*.cmake", + "Wrapping/**/*.wrap", + "Wrapping/**/*.i", + "Wrapping/**/*.swg", + "Wrapping/**/*.in", + "Wrapping/**/*.notwrapped", + "Wrapping/**/*.py", + "Wrapping/**/*.md", + "Wrapping/**/*.txt", + "Wrapping/__init__.py", + "Modules/**/wrapping/**", + "Utilities/**/*.cmake", + "Utilities/**/*.in", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# Top-level configuration, CI, and metadata +[[annotations]] +path = [ + ".github/**", + ".gitattributes", + ".gitignore", + ".mailmap", + ".pre-commit-config.yaml", + ".readthedocs.yml", + ".gersemi.config", + "CITATION.cff", + "CODE_OF_CONDUCT.md", + "CONTRIBUTING.md", + "GOVERNANCE.md", + "README.md", + "GettingStarted.md", + "NOTICE", + "AGENTS.md", + "CLAUDE.md", + "pyproject.toml", + "pixi.lock", + "greptile.json", + "Testing/**/*.yml", + "Testing/**/*.cmake", + "Testing/**/*.sh", + "Testing/**/*.py", + "Testing/**/*.xml", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# Documentation and doxygen inputs (CC-BY-4.0 per the ITK Software Guide +# licensing for prose documentation) +[[annotations]] +path = [ + "Documentation/**/*.md", + "Documentation/**/*.rst", + "Documentation/**/*.png", + "Documentation/**/*.jpg", + "Documentation/**/*.jpeg", + "Documentation/**/*.gif", + "Documentation/**/*.svg", + "Documentation/**/*.pdf", + "Documentation/**/*.html", + "Documentation/**/*.css", + "Documentation/**/*.dox", + "Documentation/**/conf.py", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "CC-BY-4.0" + +# ----------------------------------------------------------------------------- +# Test data, baselines, and image data descriptors +# These are data files used for regression testing; licensed CC-BY-4.0 as +# non-code content. +[[annotations]] +path = [ + "**.sha512", + "**.md5", + "**.cid", + "**/test/Baseline/**", + "**/test/Input/**", + "Testing/Data/**", + "Examples/Data/**", + "Wrapping/images/**", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "CC-BY-4.0" + +# ----------------------------------------------------------------------------- +# Maintenance and utility scripts (Apache-2.0 ITK-owned) +[[annotations]] +path = [ + "Utilities/Maintenance/**/*.py", + "Utilities/Maintenance/**/*.sh", + "Utilities/Maintenance/**/*.cxx", + "Utilities/Maintenance/**/*.xsl", + "Utilities/Maintenance/**/*.css", + "Utilities/Maintenance/**/*.json", + "Utilities/Maintenance/**/*.md", + "Utilities/Maintenance/**/*.txt", + "Utilities/Hooks/**", + "Utilities/Dart/**", + "Utilities/Doxygen/**", + "Utilities/GitSetup/**", + "Utilities/KWStyle/**", + "Utilities/SetupForDevelopment.sh", + "Utilities/ITKv5Preparation/**", + "Utilities/Debugger/**", + "Utilities/Doxygen/**/*.dox", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# Examples source tree (Apache-2.0 ITK-owned) +[[annotations]] +path = [ + "Examples/**/*.cmake", + "Examples/**/*.txt", + "Examples/**/*.dox", + "Examples/**/*.md", + "Examples/**/*.py", + "Examples/**/*.in", + "Examples/README.md", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# Valgrind suppression files, icons, small binaries shipped with ITK +[[annotations]] +path = [ + "**/*.supp", + "**/*.ico", + "**/*.icns", + "**/*.xpm", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# OpenCL kernels, Objective-C++, Tcl, .inc headers, bash scripts +[[annotations]] +path = [ + "Modules/**/*.cl", + "Modules/**/*.mm", + "Modules/**/*.tcl", + "Modules/**/*.inc", + "**/*.bash", + "**/*.dic", + "Examples/**/*.sh", + "Utilities/Maintenance/strip-trailing-whitespace", + "Utilities/Maintenance/SourceTarball.bash", + "Utilities/Maintenance/update-third-party.bash", + "Utilities/Maintenance/clang-format.bash", + "Utilities/Maintenance/cmake-format.bash", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# A handful of ITK-owned files still carry the legacy Insight Software +# Consortium header instead of the NumFOCUS copyright line. REUSE cannot +# derive the license from the old-style header, so annotate them explicitly. +[[annotations]] +path = [ + "Modules/Registration/FEM/test/itkVTKTetrahedralMeshReader.h", + "Modules/Registration/FEM/test/itkVTKTetrahedralMeshReader.hxx", +] +precedence = "override" +SPDX-FileCopyrightText = "Copyright 1999-2019 Insight Software Consortium" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# Vendored-in-tree code under non-ThirdParty paths: fdstream.hxx is the +# Josuttis fdstream header distributed with an ITK-local permissive notice; +# dsrc2c.c is SLATEC-derived Netlib code. +[[annotations]] +path = "Modules/IO/ImageBase/include/itkfdstream/fdstream.hxx" +precedence = "override" +SPDX-FileCopyrightText = "Copyright 2001 Nicolai M. Josuttis" +SPDX-License-Identifier = "LicenseRef-Josuttis-fdstream" +SPDX-LicenseComments = "Permissive license granted in file; see source header for full text." + +[[annotations]] +path = "Modules/Numerics/FEM/src/dsrc2c.c" +precedence = "override" +SPDX-FileCopyrightText = "NOASSERTION" +SPDX-License-Identifier = "LicenseRef-Netlib-SLATEC" +SPDX-LicenseComments = "Netlib SLATEC public-domain mathematical library." + +# ----------------------------------------------------------------------------- +# Repo-wide dotfiles, READMEs, and module-local KWStyle overrides +[[annotations]] +path = [ + ".clang-format", + ".clang-tidy", + ".editorconfig", + ".git-blame-ignore-revs", + ".git-remote-files", + ".zenodo.json", + ".ExternalData/**", + "**/.clang-format", + "**/.clang-tidy", + "**/.gitignore", + "**/README", + "**/README.md", + "**/README.rst", + "**/ITKKWStyleOverwrite.txt", + "Utilities/CMakeFormat/**", + "Utilities/ITKMigrationPreparation/**", + "Documentation/docs/**", + "Documentation/Art/**", + "Documentation/Doxygen/**", + "Modules/**/README", + "Modules/**/README.md", + "Modules/**/README.rst", + "Modules/External/**", + "Modules/Remote/**", + "Modules/**/*.dic", + "Modules/**/*.in", +] +precedence = "aggregate" +SPDX-FileCopyrightText = "Copyright NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" + + +# ----------------------------------------------------------------------------- +# The LICENSE file itself is the full Apache-2.0 text. Self-identifying. +[[annotations]] +path = "LICENSE" +precedence = "override" +SPDX-FileCopyrightText = "NOASSERTION" +SPDX-License-Identifier = "Apache-2.0" + +# ----------------------------------------------------------------------------- +# The NOTICE file is an Apache-2.0 attribution document. +[[annotations]] +path = "NOTICE" +precedence = "override" +SPDX-FileCopyrightText = "Copyright 1999-2019 Insight Software Consortium, Copyright 2020-present NumFOCUS" +SPDX-License-Identifier = "Apache-2.0" From 7a3a26fd6f83ee744f045e18999827571b9d6407 Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Sun, 19 Apr 2026 08:40:59 -0500 Subject: [PATCH 5/6] ENH: Add SPDX file-header tool and pre-commit enforcement Adds Utilities/SPDX/add_headers.py, a utility that prepends the two SPDX lines SPDX-FileCopyrightText: Copyright NumFOCUS SPDX-License-Identifier: Apache-2.0 to ITK-owned C/C++, Python, and CMake source files. It is idempotent, skips files that already carry an SPDX header, and handles shebangs, UTF-8 BOM, and CRLF line endings without clobbering them. Also wires in a local pre-commit hook (check-spdx-headers) that runs `add_headers.py --check --files ` against staged files so that new commits cannot reintroduce files without SPDX headers. The walker-mode invocation (`python3 Utilities/SPDX/add_headers.py `) is available for one-off retroactive migrations. --- .pre-commit-config.yaml | 7 + Utilities/SPDX/add_headers.py | 264 ++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100755 Utilities/SPDX/add_headers.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5a497dbc86b..e11ecf03b73 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -97,6 +97,13 @@ repos: | \.c$ # Exclude .c files - repo: local hooks: + - id: check-spdx-headers + name: 'check SPDX license headers' + entry: 'python3 Utilities/SPDX/add_headers.py --check --files' + language: system + files: '\.(h|hxx|cxx|txx|py|cmake)$|CMakeLists\.txt$' + exclude: "\\/ThirdParty\\/|\\/.pixi\\/|\\/Data\\/|\\/cmake-build.*\\/" + stages: [pre-commit] - id: local-prepare-commit-msg name: 'local prepare-commit-msg' entry: 'Utilities/Hooks/prepare-commit-msg' diff --git a/Utilities/SPDX/add_headers.py b/Utilities/SPDX/add_headers.py new file mode 100755 index 00000000000..4999a76676c --- /dev/null +++ b/Utilities/SPDX/add_headers.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright NumFOCUS +# SPDX-License-Identifier: Apache-2.0 +"""Add SPDX license and copyright headers to ITK source files. + +Follows VTK's convention: two // comment lines before the existing +license block. + +For C/C++ files (.h, .hxx, .cxx, .txx): + // SPDX-FileCopyrightText: Copyright NumFOCUS + // SPDX-License-Identifier: Apache-2.0 + /*========================================================================= + ...existing header... + +For Python files (.py): + # SPDX-FileCopyrightText: Copyright NumFOCUS + # SPDX-License-Identifier: Apache-2.0 + # ========================================================================== + ...existing header... + +For CMake files (.cmake, CMakeLists.txt): + # SPDX-FileCopyrightText: Copyright NumFOCUS + # SPDX-License-Identifier: Apache-2.0 + ...existing content... + +Only modifies files that: + - Contain "Copyright NumFOCUS" (ITK's standard header) + - Do NOT already contain "SPDX-License-Identifier" + - Are NOT under ThirdParty directories + +Usage: + # Walk the whole tree (repo root deduced from script location): + python3 add_headers.py [--dry-run] [ITK_SOURCE_DIR] + + # Operate on a specific list of files (for pre-commit integration): + python3 add_headers.py --files ... + + # Check-only mode: exit non-zero if any file needs SPDX. Intended + # for use as a pre-commit hook. + python3 add_headers.py --check --files ... +""" + +import argparse +import sys +from pathlib import Path + +from _common import ( + ITK_SPDX_COPYRIGHT as SPDX_COPYRIGHT, +) +from _common import ( + ITK_SPDX_LICENSE as SPDX_LICENSE, +) +from _common import ( + repo_root_from_script, +) + +C_HEADER = ( + f"// SPDX-FileCopyrightText: {SPDX_COPYRIGHT}\n" + f"// SPDX-License-Identifier: {SPDX_LICENSE}\n" +) + +HASH_HEADER = ( + f"# SPDX-FileCopyrightText: {SPDX_COPYRIGHT}\n" + f"# SPDX-License-Identifier: {SPDX_LICENSE}\n" +) + +C_EXTENSIONS = {".h", ".hxx", ".cxx", ".txx"} +HASH_EXTENSIONS = {".py", ".cmake"} +# CMakeLists.txt handled separately by name + +SKIP_PATTERNS = [ + "/ThirdParty/", + "/.pixi/", + "/cmake-build", + "/build", +] + + +def should_skip(path: Path) -> bool: + s = str(path) + return any(pat in s for pat in SKIP_PATTERNS) + + +UTF8_BOM = "\ufeff" + + +def needs_spdx(content: str) -> bool: + # Check only the first 50 lines so that scripts which legitimately + # mention 'SPDX-License-Identifier' in their prose (e.g. the SPDX + # tooling scripts themselves) are not falsely treated as already-SPDX. + header_region = "\n".join(content.splitlines()[:50]) + return ( + "Copyright NumFOCUS" in content + and "SPDX-License-Identifier" not in header_region + ) + + +def detect_line_ending(content: str) -> str: + """Return the dominant line ending of the file: '\\r\\n' or '\\n'.""" + crlf = content.count("\r\n") + lf = content.count("\n") - crlf + return "\r\n" if crlf > lf else "\n" + + +def add_spdx_header(content: str, header: str) -> str: + """Prepend the SPDX header while preserving BOM and line endings.""" + # Preserve an existing UTF-8 BOM by stripping, prepending SPDX, and + # re-attaching the BOM at byte 0. + bom = "" + if content.startswith(UTF8_BOM): + bom = UTF8_BOM + content = content[len(UTF8_BOM) :] + + # Match the file's dominant line ending for the inserted SPDX lines. + eol = detect_line_ending(content) + if eol != "\n": + header = header.replace("\n", eol) + + # Insert after a shebang line when present (Python scripts). + if content.startswith("#!"): + first_eol = content.find(eol) + if first_eol < 0: + # Shebang without a trailing newline: append one. + return bom + content + eol + header + first_eol += len(eol) + return bom + content[:first_eol] + header + content[first_eol:] + + return bom + header + content + + +def process_file(path: Path, dry_run: bool) -> bool: + """Add SPDX header to a single file. Returns True if modified.""" + # Read bytes and decode explicitly so we can detect BOM and CRLF + # without Python's universal-newlines translation clobbering them. + raw = path.read_bytes() + try: + content = raw.decode("utf-8") + except UnicodeDecodeError: + content = raw.decode("utf-8", errors="replace") + + if not needs_spdx(content): + return False + + suffix = path.suffix + name = path.name + + if suffix in C_EXTENSIONS: + header = C_HEADER + elif suffix in HASH_EXTENSIONS or name == "CMakeLists.txt": + header = HASH_HEADER + else: + return False + + new_content = add_spdx_header(content, header) + + if dry_run: + print(f" would modify: {path}") + else: + # Write bytes to preserve the chosen EOL / BOM exactly. + path.write_bytes(new_content.encode("utf-8")) + + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source_dir", nargs="?", default=None) + parser.add_argument( + "--dry-run", action="store_true", help="Show what would be changed" + ) + parser.add_argument( + "--check", + action="store_true", + help="Check-only mode: report missing headers and exit non-zero. " + "Intended for use as a pre-commit hook. Implies --dry-run.", + ) + parser.add_argument( + "--files", + nargs="+", + default=None, + help="Operate only on the listed files instead of walking a tree. " + "When set, source_dir is ignored.", + ) + args = parser.parse_args() + + dry_run = args.dry_run or args.check + + if args.files: + # Pre-commit-style invocation: act on the explicit file list. + modified = 0 + scanned = 0 + for file_arg in args.files: + path = Path(file_arg) + if should_skip(path) or not path.is_file(): + continue + suffix = path.suffix + name = path.name + if ( + suffix not in C_EXTENSIONS + and suffix not in HASH_EXTENSIONS + and name != "CMakeLists.txt" + ): + continue + scanned += 1 + if process_file(path, dry_run): + modified += 1 + + if args.check: + if modified: + print( + f"ERROR: {modified} staged file(s) are missing the SPDX " + f"header. Run:\n" + f" python3 Utilities/SPDX/add_headers.py " + f"--files \n" + f"to add them, then stage the result and re-commit.", + file=sys.stderr, + ) + return 1 + return 0 + + action = "Would modify" if dry_run else "Modified" + print(f"Scanned {scanned} files, {action} {modified}") + return 0 + + if args.source_dir: + root = Path(args.source_dir) + else: + root = repo_root_from_script(__file__) + + if not (root / "CMakeLists.txt").exists(): + print(f"ERROR: {root} does not look like an ITK source tree", file=sys.stderr) + return 1 + + # Collect candidate files + globs = [ + "**/*.h", + "**/*.hxx", + "**/*.cxx", + "**/*.txx", + "**/*.py", + "**/*.cmake", + "**/CMakeLists.txt", + ] + + modified = 0 + scanned = 0 + + for pattern in globs: + for path in sorted(root.glob(pattern)): + if should_skip(path): + continue + scanned += 1 + if process_file(path, dry_run): + modified += 1 + + action = "Would modify" if dry_run else "Modified" + print(f"Scanned {scanned} files, {action} {modified}") + if args.check and modified: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 092e1c48776ae965940db968b444dae225907380 Mon Sep 17 00:00:00 2001 From: Hans Johnson Date: Sun, 19 Apr 2026 08:41:09 -0500 Subject: [PATCH 6/6] DOC: Document SBOM and SPDX tooling under Utilities/SPDX/ Adds Utilities/SPDX/README.md summarizing every script in the directory (generate_sbom, add_headers, verify_versions, validate_light, validate_with_spdx_tools, compute_fingerprint), their CTest integrations, and typical workflows for adding SPDX headers, verifying vendored-dependency versions, validating the generated SBOM, and tracking drift via the fingerprint baseline. Adds Utilities/SPDX/TODO.md enumerating the SPDX 2.3 best-practice enhancements intentionally scoped out of the initial landing so reviewers can pick them up as follow-ups (documentNamespace hosting, release-asset attachment, packageVerificationCode, CONTAINS / BUILD_TOOL_OF relationship enrichment, continuous reuse-lint in CI, PURL validation, pytest coverage for generate_sbom.py). Leaves a one-line pointer in Utilities/Maintenance/README.md to the new Utilities/SPDX/ location so existing links remain valid. --- Utilities/Maintenance/README.md | 7 +++ Utilities/SPDX/README.md | 61 ++++++++++++++++++++++ Utilities/SPDX/TODO.md | 91 +++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 Utilities/SPDX/README.md create mode 100644 Utilities/SPDX/TODO.md diff --git a/Utilities/Maintenance/README.md b/Utilities/Maintenance/README.md index 8adcebe2c21..36039d9a547 100644 --- a/Utilities/Maintenance/README.md +++ b/Utilities/Maintenance/README.md @@ -8,3 +8,10 @@ Note that the files in this directory are not tested automatically by the continuous integration (CI) of the ITK git repository. So a git commit that only modifies files in this directory should not trigger a run of the CI. + +## SBOM and SPDX Tooling + +SBOM generation, validation, drift detection, and SPDX header +maintenance scripts have moved to `Utilities/SPDX/`. See +[Utilities/SPDX/README.md](../SPDX/README.md) for the full list of +scripts and typical workflows. diff --git a/Utilities/SPDX/README.md b/Utilities/SPDX/README.md new file mode 100644 index 00000000000..1615f36507a --- /dev/null +++ b/Utilities/SPDX/README.md @@ -0,0 +1,61 @@ +# SPDX and SBOM Tooling + +This directory holds the scripts that support ITK's SPDX-2.3 Software +Bill of Materials workflow (see `CMake/ITKSBOMGeneration.cmake` and +`CMake/ITKSBOMValidation.cmake`). All scripts require Python 3.10 or +later. Shared constants, JSON I/O, and path helpers live in +`_common.py`. + +| Script | Purpose | +|--------|---------| +| `generate_sbom.py` | Read the `sbom-inputs.json` manifest emitted by `ITKSBOMGeneration.cmake` and write the final `sbom.spdx.json`. Invoked from CMake; may also be run by hand for debugging. | +| `add_headers.py` | Prepend `SPDX-FileCopyrightText` / `SPDX-License-Identifier` lines to ITK-owned source files. Idempotent; skips files that already carry an SPDX header. Used by the `check-spdx-headers` pre-commit hook with `--check --files ` to enforce SPDX on new files. Handles shebangs, UTF-8 BOM, and CRLF line endings safely. | +| `verify_versions.py` | Cross-check that each ThirdParty module's `SPDX_VERSION` in `itk-module.cmake` matches the tag declared in its `UpdateFromUpstream.sh`. Skips modules tracking `master`, commit SHAs, or ITK-custom `for/*` tags. Invoked by CTest as `ITKSBOMVersionConsistency`. | +| `validate_light.py` | In-tree lightweight validator for the generated SBOM. Checks required SPDX 2.3 fields, license-reference integrity, and SPDXID uniqueness. Always runs via CTest `ITKSBOMValidation`. | +| `validate_with_spdx_tools.py` | Full SPDX 2.3 schema validation via the optional `spdx-tools` pip package. Returns CTest skip code 77 when `spdx-tools` is not installed so the test is optional rather than a hard dependency. Invoked by CTest as `ITKSBOMSchemaValidation`. | +| `compute_fingerprint.py` | SHA-256 fingerprint over the sorted, canonicalized SBOM package metadata (name, version, license, PURL). Used for drift detection between branches and in CI via CTest `ITKSBOMFingerprint` when `ITK_SBOM_FINGERPRINT_BASELINE` is set. | + +### Typical workflows + +Add SPDX headers to new files: +``` +python3 Utilities/SPDX/add_headers.py +``` + +Verify SBOM matches upstream versions: +``` +python3 Utilities/SPDX/verify_versions.py . +``` + +Validate a generated SBOM: +``` +python3 Utilities/SPDX/validate_light.py build/sbom.spdx.json +# For full schema validation (pip install spdx-tools first): +python3 Utilities/SPDX/validate_with_spdx_tools.py build/sbom.spdx.json +``` + +Track SBOM changes: +``` +# Baseline the current state: +python3 Utilities/SPDX/compute_fingerprint.py build/sbom.spdx.json \ + --compare Utilities/SPDX/sbom-fingerprint.baseline --update + +# Later, verify no drift: +python3 Utilities/SPDX/compute_fingerprint.py build/sbom.spdx.json \ + --compare Utilities/SPDX/sbom-fingerprint.baseline +``` + +### Future improvements + +See [TODO.md](./TODO.md) for planned SPDX best-practice enhancements +that were scoped out of the initial landing. + +### Related files + +- `Utilities/KWStyle/ITKHeader.h` — canonical ITK file header template, + enforced by the KWStyle CTest. Starts with the two SPDX lines followed + by the Apache-2.0 notice block. +- `REUSE.toml` (repo root) — REUSE 3.x blanket license annotations for + ITK-owned files that do not carry per-file SPDX headers. +- `LICENSES/` (repo root) — canonical SPDX license texts required by + REUSE 3.x. diff --git a/Utilities/SPDX/TODO.md b/Utilities/SPDX/TODO.md new file mode 100644 index 00000000000..61140109539 --- /dev/null +++ b/Utilities/SPDX/TODO.md @@ -0,0 +1,91 @@ +# Future SPDX / SBOM Compliance Work + +These enhancements were scoped out of the initial SBOM landing in PR +#5817 but are worth revisiting as follow-up issues. Ordered roughly +by cost-to-payoff. + +## 1. Host `documentNamespace` URIs at `itk.org/spdx/` (recommended) + +SPDX 2.3 §6.5 does *not* require `documentNamespace` URIs to resolve, +but Annex B "best practices" recommends that creators who own the +domain make them resolvable. Three progressive options: + +- **Minimum (cheapest):** add a redirect on `itk.org` so any + `/spdx/...` path 302s to + `https://github.com/InsightSoftwareConsortium/ITK/releases`. + One Apache/nginx rule; never needs updating. +- **Better:** publish a static `itk.org/spdx/index.html` explaining + what the namespace URIs are and pointing to the GitHub release + SBOM assets. +- **Automated:** extend the release workflow so each tagged release + uploads `sbom.spdx.json` to `itk.org/spdx/ITK--.spdx.json` + (and attaches it as a GitHub release asset, see item 2). + Cadence: once per release (handful per year). No per-build work. + +Only release SBOMs are worth hosting — the configure-time SBOM +produced for every developer build tree is ephemeral and should not +be published. + +## 2. Attach `sbom.spdx.json` to every GitHub release (recommended) + +Extend the existing release workflow (`.github/workflows/*release*`) +to upload the SBOM produced by a canonical CI configuration as a +release asset. This is the single most-requested SBOM consumption +pattern (FDA 524B submissions, anchore/syft/grype ingestion). + +## 3. `filesAnalyzed: true` + `packageVerificationCode` for `SPDXRef-ITK` + +NTIA "minimum elements" baseline encourages emitting a +`packageVerificationCode` — a SHA1 over the sorted list of file +SHA1s inside the package — for the top-level ITK package on release +builds. The current SBOM sets `filesAnalyzed: false` everywhere to +avoid the per-file walk. Implementation notes: + +- Add an opt-in CMake cache option (off by default, on in release + CI) to walk the install tree and feed file hashes into + `generate_sbom.py`. +- When on, flip `filesAnalyzed` to `true` on `SPDXRef-ITK` only, + emit `packageVerificationCode`, and list file-level SPDX records. + +Non-trivial — expected cost: one full-day implementation, plus +triage of any files (test data, generated headers) that must be +excluded from the hash set. + +## 4. `CONTAINS` / `BUILD_TOOL_OF` relationships + +Some downstream SBOM consumers prefer richer relationship graphs +than the current `DESCRIBES` + `DEPENDS_ON` pair. Adding: + +- `SPDXRef-ITK CONTAINS SPDXRef-` for each vendored + ThirdParty module (distinct from the existing `DEPENDS_ON` to + external-only dependencies like FFTW). +- `SPDXRef- BUILD_TOOL_OF SPDXRef-ITK` for CMake / + Python / Ninja if we ever decide to record them as packages. + +Low cost; requires deciding whether ThirdParty modules are +"contained" (they are, physically) or merely "depended on". + +## 5. Continuous REUSE 3.x compliance + +Add a `reuse lint --quiet` job to CI (either as a new pre-commit +hook or a dedicated GitHub Actions step) so `REUSE.toml`, per-file +SPDX headers, and `LICENSES/` stay in sync. The initial landing +confirmed local `reuse lint` passes; this just prevents regression. + +## 6. Stronger PURL validation + +`generate_sbom.py` currently copies `purl` values through verbatim. +Adding a `packageurl-python` parse step would catch malformed PURLs +at SBOM generation time (affects CVE-feed tools downstream). Gated +on an optional pip dependency so it remains opt-in, mirroring the +existing `spdx-tools` pattern in `validate_with_spdx_tools.py`. + +## 7. Test coverage for `generate_sbom.py` + +Add `Utilities/SPDX/tests/` with pytest coverage for: +- manifest parsing (blocks, key=value, escaping round-trip) +- LicenseRef format validation +- `build_sbom` output shape +- CTest wire-up so the suite runs under `ITK_BUILD_SPDX_TESTS`. + +Pure Python, no ITK build dependency — cheap to add.