From cb0fc5f7921a78a4d3af25b8ad8e3d401e29e126 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Thu, 15 Oct 2026 13:38:29 +0100 Subject: [PATCH 01/13] tools,build: add CycloneDX SBOM generation Add tools/generate_sbom.py, a stdlib-only Python 3 script that writes a CycloneDX 1.5 SBOM describing the bundled dependencies of the Node.js binary. The script reads config.gypi to determine which dependencies are active in the current build, then extracts version strings from whichever file each dependency uses to record its version: C headers, CMakeLists.txt, package.json, Cargo.toml and others. A dependency whose version cannot be read is reported as "unknown" with a warning rather than failing the run. Dependencies satisfied by a system library or disabled via a configure flag are emitted with scope "excluded", so the document reflects the build that was configured. Output defaults to out/Release/node-v{version}-sbom.json. New Makefile targets: make sbom - generate the SBOM (requires ./configure first) make test-sbom - validate the generated SBOM doc/contributing/maintaining/maintaining-dependencies.md now asks for a DEP_CATALOG entry when adding a dependency, so new deps do not silently go missing from the SBOM. Unit tests in test/tools/test_generate_sbom.py cover catalog structural correctness, the version extractors, scope resolution and deps/ coverage. They run under the existing `make tooltest` target. Refs: https://github.com/nodejs/security-wg/issues/1115 Assisted-by: IBM Bob Signed-off-by: Beth Griggs --- Makefile | 8 + .../maintaining/maintaining-dependencies.md | 10 + test/tools/test_generate_sbom.py | 249 +++++++ tools/generate_sbom.py | 632 ++++++++++++++++++ 4 files changed, 899 insertions(+) create mode 100644 test/tools/test_generate_sbom.py create mode 100644 tools/generate_sbom.py diff --git a/Makefile b/Makefile index 31d7d911c987..609f769be03a 100644 --- a/Makefile +++ b/Makefile @@ -874,6 +874,14 @@ tools/doc/node_modules: tools/doc/package.json RAWVER=$(shell $(PYTHON) tools/getnodeversion.py) VERSION=v$(RAWVER) +.PHONY: sbom +sbom: config.gypi ## Write a CycloneDX SBOM of the bundled dependencies to out/Release/node-$(VERSION)-sbom.json. + $(PYTHON) tools/generate_sbom.py + +.PHONY: test-sbom +test-sbom: ## Validate the SBOM written by `make sbom`. + $(PYTHON) tools/generate_sbom.py --validate + .PHONY: doc-only .NOTPARALLEL: doc-only doc-only: $(apidoc_dirs) $(apidocs_html) $(apidocs_json) out/doc/api/all.json out/doc/llms.txt out/doc/apilinks.json ## Builds the docs with the local or the global Node.js binary. diff --git a/doc/contributing/maintaining/maintaining-dependencies.md b/doc/contributing/maintaining/maintaining-dependencies.md index e84307620b9c..f28e06f7f1e8 100644 --- a/doc/contributing/maintaining/maintaining-dependencies.md +++ b/doc/contributing/maintaining/maintaining-dependencies.md @@ -106,6 +106,12 @@ shared library is available can added by: }], ``` +* add an entry to the `DEP_CATALOG` list in `tools/generate_sbom.py` so the + dependency appears in the generated SBOM. Set `shared_flag` to the + `node_shared_depname` variable and `version_fn` to an extractor for + whichever file in the dependency records its version. See the comment + above `DEP_CATALOG` and the existing entries for examples. + ## Supporting externalizable dependencies with JavaScript code Support for an externalizable dependency with JavaScript code @@ -146,6 +152,10 @@ can be added as a non-externalizable dependency. In this case simply add the path to the JavaScript file in the `deps_files` list in the `node.gyp` file. +Also add an entry to the `DEP_CATALOG` list in `tools/generate_sbom.py`, with +`version_fn` reading the dependency's `package.json`, so the dependency +appears in the generated SBOM. + ## Common approach for dependencies with WASM components WASM components within dependencies are most often built diff --git a/test/tools/test_generate_sbom.py b/test/tools/test_generate_sbom.py new file mode 100644 index 000000000000..5410a96dc43a --- /dev/null +++ b/test/tools/test_generate_sbom.py @@ -0,0 +1,249 @@ +import contextlib +import io +import os +import sys +import unittest +from os import path + +ROOT = path.abspath(path.join(path.dirname(__file__), '..', '..')) +sys.path.insert(0, path.join(ROOT, 'tools')) +import generate_sbom as sbom + + +class TestDepCatalog(unittest.TestCase): + """Validate that the DEP_CATALOG entries are structurally correct.""" + + def test_all_entries_have_required_fields(self): + for dep in sbom.DEP_CATALOG: + for field in ('name', 'description', 'purl_base', 'version_fn'): + self.assertIn(field, dep, + 'DEP_CATALOG entry %r is missing field %r' % (dep.get('name'), field)) + + def test_version_fn_is_callable(self): + for dep in sbom.DEP_CATALOG: + self.assertTrue(callable(dep['version_fn']), + 'version_fn for %r must be callable' % dep['name']) + + def test_scope_values(self): + valid_scopes = {'required', 'optional', 'excluded'} + for dep in sbom.DEP_CATALOG: + scope = dep.get('scope', 'required') + self.assertIn(scope, valid_scopes, + 'invalid scope %r for %r' % (scope, dep['name'])) + + def test_component_type_values(self): + valid_types = {'library', 'framework'} + for dep in sbom.DEP_CATALOG: + component_type = dep.get('component_type', 'library') + self.assertIn(component_type, valid_types, + 'invalid component_type %r for %r' % (component_type, dep['name'])) + + def test_purl_base_has_no_version(self): + for dep in sbom.DEP_CATALOG: + self.assertTrue(dep['purl_base'].startswith('pkg:'), + 'purl_base for %r must start with "pkg:"' % dep['name']) + self.assertNotIn('@', dep['purl_base'], + 'purl_base for %r must not include a version' % dep['name']) + + def test_no_duplicate_names(self): + names = [dep['name'] for dep in sbom.DEP_CATALOG] + self.assertEqual(len(names), len(set(names)), + 'DEP_CATALOG contains duplicate names: %r' % + [n for n in names if names.count(n) > 1]) + + def test_deps_dir_coverage(self): + """Every first-level subdirectory of deps/ needs a DEP_CATALOG entry.""" + deps_dir = path.join(ROOT, 'deps') + if not path.isdir(deps_dir): + self.skipTest('deps/ directory not present') + # Directories that are not dependencies in their own right. + known_non_deps = { + 'icu-tmp', # temporary download target + 'icu-small', # bundled subset of ICU, covered by the 'icu' entry + } + # Directory names that differ from the canonical dependency name. + dir_to_catalog = { + 'uv': 'libuv', + 'cares': 'c-ares', + 'histogram': 'hdr_histogram', + 'crates': 'v8-crates', + } + catalog_names = {dep['name'] for dep in sbom.DEP_CATALOG} + missing = [] + for entry in sorted(os.listdir(deps_dir)): + if not path.isdir(path.join(deps_dir, entry)) or entry in known_non_deps: + continue + if dir_to_catalog.get(entry, entry) not in catalog_names: + missing.append(entry) + self.assertEqual(missing, [], + 'deps/ subdirectories with no DEP_CATALOG entry: %r' % missing) + + +class TestVersionExtractors(unittest.TestCase): + """Smoke-test the version extractors against real files in the repo.""" + + def _require(self, *parts): + p = path.join(ROOT, *parts) + if not path.exists(p): + self.skipTest('file not present: %s' % p) + return p + + def test_version_from_package_json(self): + p = self._require('deps', 'npm', 'package.json') + self.assertRegex(sbom.version_from_package_json(p), r'^\d+\.\d+\.\d+') + + def test_version_from_c_macros(self): + p = self._require('deps', 'uv', 'include', 'uv', 'version.h') + version = sbom.version_from_c_macros( + p, 'UV_VERSION_MAJOR', 'UV_VERSION_MINOR', 'UV_VERSION_PATCH') + self.assertRegex(version, r'^\d+\.\d+\.\d+$') + + def test_version_from_string_macro(self): + p = self._require('deps', 'ada', 'ada.h') + self.assertRegex( + sbom.version_from_string_macro(p, 'ADA_VERSION'), r'^\d+\.') + + def test_version_from_cmake(self): + p = self._require('deps', 'zlib', 'CMakeLists.txt') + self.assertRegex(sbom.version_from_cmake(p), r'^\d+\.') + + def test_version_from_icu_dep(self): + p = self._require('tools', 'icu', 'current_ver.dep') + self.assertRegex(sbom.version_from_icu_dep(p), r'^\d+\.') + + def test_version_from_plain_file(self): + p = self._require('deps', 'perfetto', 'VERSION') + self.assertNotEqual(sbom.version_from_plain_file(p), sbom.UNKNOWN) + + def test_version_from_openssl(self): + self.assertRegex(sbom.version_from_openssl(sbom.REPO_ROOT), r'^\d+\.\d+\.') + + def test_missing_file_is_unknown(self): + missing = path.join(ROOT, 'deps', 'does-not-exist', 'version.h') + # The extractors warn on stderr rather than raising. + with contextlib.redirect_stderr(io.StringIO()) as stderr: + version = sbom.version_from_string_macro(missing, 'FOO_VERSION') + self.assertEqual(version, sbom.UNKNOWN) + self.assertIn('cannot read', stderr.getvalue()) + + def test_missing_macro_is_unknown(self): + p = self._require('deps', 'ada', 'ada.h') + with contextlib.redirect_stderr(io.StringIO()) as stderr: + version = sbom.version_from_string_macro(p, 'NOT_A_MACRO') + self.assertEqual(version, sbom.UNKNOWN) + self.assertIn('no match', stderr.getvalue()) + + +class TestResolveScope(unittest.TestCase): + """Verify that resolve_scope() interprets config.gypi variables correctly.""" + + def test_dependency_without_flags_is_required(self): + self.assertEqual(sbom.resolve_scope({}, {}), 'required') + + def test_disabled_use_flag_is_excluded(self): + dep = {'use_flag': 'node_use_sqlite'} + self.assertEqual( + sbom.resolve_scope(dep, {'node_use_sqlite': 'false'}), 'excluded') + + def test_enabled_use_flag_keeps_scope(self): + dep = {'use_flag': 'node_use_sqlite', 'scope': 'optional'} + self.assertEqual( + sbom.resolve_scope(dep, {'node_use_sqlite': 'true'}), 'optional') + + def test_shared_flag_is_excluded(self): + dep = {'shared_flag': 'node_shared_zlib'} + self.assertEqual( + sbom.resolve_scope(dep, {'node_shared_zlib': 'true'}), 'excluded') + + def test_bundled_shared_flag_keeps_scope(self): + dep = {'shared_flag': 'node_shared_zlib'} + self.assertEqual( + sbom.resolve_scope(dep, {'node_shared_zlib': 'false'}), 'required') + + def test_excluded_scope_stays_excluded(self): + self.assertEqual(sbom.resolve_scope({'scope': 'excluded'}, {}), 'excluded') + + def test_excluded_when_matches(self): + dep = { + 'scope': 'optional', + 'use_flag': 'node_use_quic', + 'excluded_when': [('node_shared_openssl', 'true')], + } + config_vars = {'node_use_quic': 'true', 'node_shared_openssl': 'true'} + self.assertEqual(sbom.resolve_scope(dep, config_vars), 'excluded') + + def test_excluded_when_does_not_match(self): + dep = { + 'scope': 'optional', + 'use_flag': 'node_use_quic', + 'excluded_when': [('node_shared_openssl', 'true')], + } + config_vars = {'node_use_quic': 'true', 'node_shared_openssl': 'false'} + self.assertEqual(sbom.resolve_scope(dep, config_vars), 'optional') + + +class TestBuildComponent(unittest.TestCase): + + def test_fields(self): + dep = { + 'name': 'zlib', + 'description': 'Compression library', + 'purl_base': 'pkg:github/madler/zlib', + } + component = sbom.build_component(dep, '1.3.0', 'required') + self.assertEqual(component['bom-ref'], 'zlib@1.3.0') + self.assertEqual(component['type'], 'library') + self.assertEqual(component['version'], '1.3.0') + self.assertEqual(component['scope'], 'required') + self.assertEqual(component['purl'], 'pkg:github/madler/zlib@1.3.0') + + def test_component_type_override(self): + dep = { + 'name': 'v8', + 'description': 'JavaScript engine', + 'purl_base': 'pkg:github/v8/v8', + 'component_type': 'framework', + } + self.assertEqual( + sbom.build_component(dep, '13.0.0', 'required')['type'], 'framework') + + +class TestBuildSbom(unittest.TestCase): + + def _component(self, name, version, scope='required'): + return { + 'bom-ref': '%s@%s' % (name, version), + 'type': 'library', + 'name': name, + 'version': version, + 'description': '', + 'scope': scope, + 'purl': 'pkg:generic/%s@%s' % (name, version), + } + + def test_structure(self): + doc = sbom.build_sbom('27.0.0', [self._component('zlib', '1.3.0')], []) + self.assertEqual(doc['bomFormat'], 'CycloneDX') + self.assertEqual(doc['specVersion'], '1.5') + self.assertTrue(doc['serialNumber'].startswith('urn:uuid:')) + self.assertEqual(doc['metadata']['component']['name'], 'nodejs') + self.assertEqual(doc['metadata']['component']['version'], '27.0.0') + self.assertEqual(len(doc['components']), 1) + + def test_excluded_components_are_not_root_dependencies(self): + components = [ + self._component('zlib', '1.3.0'), + self._component('googletest', 'unknown', scope='excluded'), + ] + doc = sbom.build_sbom('27.0.0', components, []) + root = doc['dependencies'][0] + self.assertEqual(root['ref'], 'nodejs@27.0.0') + self.assertEqual(root['dependsOn'], ['zlib@1.3.0']) + + def test_serial_number_is_unique(self): + self.assertNotEqual(sbom.build_sbom('27.0.0', [], [])['serialNumber'], + sbom.build_sbom('27.0.0', [], [])['serialNumber']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/generate_sbom.py b/tools/generate_sbom.py new file mode 100644 index 000000000000..af1a87c0f03e --- /dev/null +++ b/tools/generate_sbom.py @@ -0,0 +1,632 @@ +#!/usr/bin/env python3 +# Generates a CycloneDX 1.5 Software Bill of Materials (SBOM) describing the +# bundled runtime dependencies of the Node.js binary. +# +# Usage: +# python tools/generate_sbom.py [--output PATH] +# python tools/generate_sbom.py --validate [--output PATH] +# +# The output defaults to out/Release/node-v{version}-sbom.json. Generating +# requires ./configure to have been run first, as the set of dependencies that +# end up in the binary is read from config.gypi. + +import argparse +import datetime +import glob +import json +import re +import sys +import uuid +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent + +# Used when a dependency does not record its version anywhere we can read. +UNKNOWN = 'unknown' + + +def parse_config_gypi(path): + """Read config.gypi and return its `variables` dict.""" + if not path.exists(): + print(f'error: {path} not found.\n' + 'Run ./configure first to generate the build configuration.', + file=sys.stderr) + sys.exit(1) + # config.gypi is JSON apart from a leading "# Do not edit" comment. + text = path.read_text(encoding='utf-8') + json_text = '\n'.join( + line for line in text.splitlines() if not line.startswith('#')) + return json.loads(json_text).get('variables', {}) + + +def get_node_version(root): + header = (root / 'src/node_version.h').read_text(encoding='utf-8') + parts = [re.search(rf'#define\s+NODE_{part}_VERSION\s+(\d+)', header).group(1) + for part in ('MAJOR', 'MINOR', 'PATCH')] + return '.'.join(parts) + + +# --------------------------------------------------------------------------- +# Version extractors +# +# Dependencies record their version in whatever format upstream uses, so each +# one needs an extractor that knows where to look. They never raise: a +# dependency we cannot read a version for is reported as UNKNOWN with a warning +# rather than failing the whole document. +# --------------------------------------------------------------------------- + +def _read_text(path): + """Return the contents of `path`, or None with a warning if unreadable.""" + try: + return Path(path).read_text(encoding='utf-8', errors='replace') + except OSError as exc: + print(f'warning: cannot read {path}: {exc}', file=sys.stderr) + return None + + +def _search(path, pattern, flags=0): + """Return the first capture group of `pattern` in `path`, or UNKNOWN.""" + text = _read_text(path) + if text is None: + return UNKNOWN + match = re.search(pattern, text, flags) + if match is None: + print(f'warning: no match for {pattern!r} in {path}', file=sys.stderr) + return UNKNOWN + return match.group(1).strip() + + +def version_from_c_macros(path, *macros): + """Join integer #define values, e.g. FOO_MAJOR, FOO_MINOR and FOO_PATCH.""" + parts = [_search(path, rf'#\s*define\s+{macro}\s+(\d+)') for macro in macros] + return UNKNOWN if UNKNOWN in parts else '.'.join(parts) + + +def version_from_string_macro(path, macro): + """Read a string #define, e.g. #define FOO_VERSION "1.2.3".""" + return _search(path, rf'#\s*define\s+{macro}\s+"([^"]+)"') + + +def version_from_cmake(path): + """Read the VERSION argument of a CMake project() declaration.""" + return _search(path, r'project\s*\([^)]*?VERSION\s+"?([0-9][^\s")]+)', + re.IGNORECASE) + + +def version_from_package_json(path): + text = _read_text(path) + if text is None: + return UNKNOWN + try: + return json.loads(text)['version'] + except (KeyError, json.JSONDecodeError) as exc: + print(f'warning: cannot read version from {path}: {exc}', file=sys.stderr) + return UNKNOWN + + +def version_from_plain_file(path): + """Read a file whose entire contents are the version.""" + text = _read_text(path) + return UNKNOWN if text is None else text.strip() + + +def version_from_cargo_toml(path): + """Read the first `version = "1.2.3"` key, i.e. the one in [package].""" + return _search(path, r'^version\s*=\s*"([^"]+)"', re.MULTILINE) + + +def version_from_texi(path): + """Read `@set VERSION 1.2.3` from a Texinfo version file.""" + return _search(path, r'^@set\s+VERSION\s+(.+)$', re.MULTILINE) + + +def version_from_readme_node(path, field): + """Read a `Field: value` line from a deps/*/README.node metadata file.""" + return _search(path, rf'^{field}:\s*(.+)$', re.MULTILINE) + + +def version_from_icu_dep(path): + """Read the ICU version from the source URL in tools/icu/current_ver.dep.""" + return _search(path, r'icu4c-([^-]+)-sources\.tgz') + + +def version_from_openssl(root): + """OpenSSL's generated version header is per-architecture; any copy will do.""" + matches = sorted(glob.glob(str( + root / 'deps/openssl/config/archs/*/asm/include/openssl/opensslv.h'))) + if not matches: + print('warning: no architecture-specific opensslv.h found', file=sys.stderr) + return UNKNOWN + return version_from_string_macro(matches[0], 'OPENSSL_VERSION_STR') + + +# --------------------------------------------------------------------------- +# DEP_CATALOG +# +# One entry per bundled dependency: +# +# name canonical dependency name +# description short human-readable description +# purl_base package URL, without the "@" suffix appended here +# version_fn callable(root: Path) -> version string +# component_type CycloneDX component type, 'library' (default) or 'framework' +# scope CycloneDX scope when the dependency is part of the build: +# 'required' (default), 'optional', or 'excluded' for +# dependencies that are never linked into the binary +# use_flag config.gypi variable that must not be 'false' +# shared_flag config.gypi variable that is 'true' when a system library +# is used instead of the bundled copy +# excluded_when (variable, value) pairs that also exclude the dependency +# +# Only name, description, purl_base and version_fn are required; the rest +# default as described above. +# +# When adding a bundled dependency to deps/, add an entry here and point +# version_fn at whichever file in the dependency records its version. Set +# use_flag and shared_flag to match the variable names declared in the +# 'variables' block of node.gyp. +# +# JavaScript files under lib/ do not need an entry: they are listed +# automatically from node_library_files in config.gypi. +# --------------------------------------------------------------------------- + +DEP_CATALOG = [ + # --- Runtime C/C++ dependencies --- + { + 'name': 'openssl', + 'description': 'TLS/SSL and general-purpose cryptography library', + 'purl_base': 'pkg:github/openssl/openssl', + 'shared_flag': 'node_shared_openssl', + 'use_flag': 'node_use_openssl', + 'version_fn': version_from_openssl, + }, + { + 'name': 'zlib', + 'description': 'General-purpose lossless data compression library', + 'purl_base': 'pkg:github/madler/zlib', + 'shared_flag': 'node_shared_zlib', + 'version_fn': lambda root: version_from_cmake( + root / 'deps/zlib/CMakeLists.txt'), + }, + { + 'name': 'libuv', + 'description': 'Multi-platform support library with a focus on ' + 'asynchronous I/O', + 'purl_base': 'pkg:github/libuv/libuv', + 'shared_flag': 'node_shared_libuv', + 'version_fn': lambda root: version_from_c_macros( + root / 'deps/uv/include/uv/version.h', + 'UV_VERSION_MAJOR', 'UV_VERSION_MINOR', 'UV_VERSION_PATCH'), + }, + { + 'name': 'v8', + 'description': "Google's open source JavaScript and WebAssembly engine", + 'purl_base': 'pkg:github/v8/v8', + 'component_type': 'framework', + 'use_flag': 'node_use_bundled_v8', + 'version_fn': lambda root: version_from_c_macros( + root / 'deps/v8/include/v8-version.h', + 'V8_MAJOR_VERSION', 'V8_MINOR_VERSION', 'V8_BUILD_NUMBER'), + }, + { + 'name': 'brotli', + 'description': 'Brotli compression algorithm', + 'purl_base': 'pkg:github/google/brotli', + 'shared_flag': 'node_shared_brotli', + 'version_fn': lambda root: version_from_c_macros( + root / 'deps/brotli/c/common/version.h', + 'BROTLI_VERSION_MAJOR', 'BROTLI_VERSION_MINOR', 'BROTLI_VERSION_PATCH'), + }, + { + 'name': 'c-ares', + 'description': 'Asynchronous DNS resolver library', + 'purl_base': 'pkg:github/c-ares/c-ares', + 'shared_flag': 'node_shared_cares', + 'version_fn': lambda root: version_from_cmake( + root / 'deps/cares/CMakeLists.txt'), + }, + { + 'name': 'llhttp', + 'description': 'HTTP/1.1 request and response parser', + 'purl_base': 'pkg:github/nodejs/llhttp', + 'shared_flag': 'node_shared_http_parser', + 'version_fn': lambda root: version_from_cmake( + root / 'deps/llhttp/CMakeLists.txt'), + }, + { + 'name': 'nghttp2', + 'description': 'HTTP/2 C library', + 'purl_base': 'pkg:github/nghttp2/nghttp2', + 'shared_flag': 'node_shared_nghttp2', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/nghttp2/lib/includes/nghttp2/nghttp2ver.h', + 'NGHTTP2_VERSION'), + }, + { + 'name': 'ngtcp2', + 'description': 'QUIC protocol implementation', + 'purl_base': 'pkg:github/ngtcp2/ngtcp2', + 'scope': 'optional', + 'use_flag': 'node_use_quic', + 'shared_flag': 'node_shared_ngtcp2', + # QUIC is only built against the bundled OpenSSL. + 'excluded_when': [('node_shared_openssl', 'true')], + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/ngtcp2/ngtcp2/lib/includes/ngtcp2/version.h', + 'NGTCP2_VERSION'), + }, + { + 'name': 'nghttp3', + 'description': 'HTTP/3 library, bundled inside deps/ngtcp2', + 'purl_base': 'pkg:github/ngtcp2/nghttp3', + 'scope': 'optional', + 'use_flag': 'node_use_quic', + 'shared_flag': 'node_shared_nghttp3', + 'excluded_when': [('node_shared_openssl', 'true')], + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/ngtcp2/nghttp3/lib/includes/nghttp3/version.h', + 'NGHTTP3_VERSION'), + }, + { + 'name': 'ada', + 'description': 'Fast WHATWG-compliant URL parser', + 'purl_base': 'pkg:github/ada-url/ada', + 'shared_flag': 'node_shared_ada', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/ada/ada.h', 'ADA_VERSION'), + }, + { + 'name': 'simdjson', + 'description': 'SIMD-accelerated JSON parser', + 'purl_base': 'pkg:github/simdjson/simdjson', + 'shared_flag': 'node_shared_simdjson', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/simdjson/simdjson.h', 'SIMDJSON_VERSION'), + }, + { + 'name': 'sqlite', + 'description': 'Self-contained SQL database engine', + 'purl_base': 'pkg:github/sqlite/sqlite', + 'scope': 'optional', + 'use_flag': 'node_use_sqlite', + 'shared_flag': 'node_shared_sqlite', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/sqlite/sqlite3.h', 'SQLITE_VERSION'), + }, + { + 'name': 'uvwasi', + 'description': 'WASI syscall API built atop libuv', + 'purl_base': 'pkg:github/nodejs/uvwasi', + 'shared_flag': 'node_shared_uvwasi', + 'version_fn': lambda root: version_from_c_macros( + root / 'deps/uvwasi/include/uvwasi.h', + 'UVWASI_VERSION_MAJOR', 'UVWASI_VERSION_MINOR', 'UVWASI_VERSION_PATCH'), + }, + { + 'name': 'zstd', + 'description': 'Fast lossless compression algorithm', + 'purl_base': 'pkg:github/facebook/zstd', + 'shared_flag': 'node_shared_zstd', + # The third component is RELEASE rather than PATCH. + 'version_fn': lambda root: version_from_c_macros( + root / 'deps/zstd/lib/zstd.h', + 'ZSTD_VERSION_MAJOR', 'ZSTD_VERSION_MINOR', 'ZSTD_VERSION_RELEASE'), + }, + { + 'name': 'hdr_histogram', + 'description': 'High Dynamic Range (HDR) Histogram', + 'purl_base': 'pkg:github/HdrHistogram/HdrHistogram_c', + 'shared_flag': 'node_shared_hdr_histogram', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/histogram/include/hdr/hdr_histogram_version.h', + 'HDR_HISTOGRAM_VERSION'), + }, + { + 'name': 'perfetto', + 'description': 'Performance instrumentation and tracing SDK', + 'purl_base': 'pkg:github/google/perfetto', + 'scope': 'optional', + 'shared_flag': 'node_shared_perfetto', + 'version_fn': lambda root: version_from_plain_file( + root / 'deps/perfetto/VERSION'), + }, + { + 'name': 'LIEF', + 'description': 'Library to Instrument Executable Formats', + 'purl_base': 'pkg:github/lief-project/LIEF', + 'scope': 'optional', + 'use_flag': 'node_use_lief', + 'shared_flag': 'node_shared_lief', + # LIEF_VERSION has a trailing '-', so build it from the numeric macros. + 'version_fn': lambda root: version_from_c_macros( + root / 'deps/LIEF/include/LIEF/version.h', + 'LIEF_VERSION_MAJOR', 'LIEF_VERSION_MINOR', 'LIEF_VERSION_PATCH'), + }, + { + 'name': 'libffi', + 'description': 'Portable foreign-function interface library', + 'purl_base': 'pkg:github/libffi/libffi', + 'scope': 'optional', + 'use_flag': 'node_use_ffi', + 'shared_flag': 'node_shared_ffi', + 'version_fn': lambda root: version_from_texi( + root / 'deps/libffi/doc/version.texi'), + }, + { + 'name': 'merve', + 'description': 'String interning library', + 'purl_base': 'pkg:github/anonrig/merve', + 'shared_flag': 'node_shared_merve', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/merve/merve.h', 'MERVE_VERSION'), + }, + { + 'name': 'nbytes', + 'description': 'Byte manipulation utilities for Node.js', + 'purl_base': 'pkg:github/nodejs/nbytes', + 'shared_flag': 'node_shared_nbytes', + 'version_fn': lambda root: version_from_string_macro( + root / 'deps/nbytes/include/nbytes.h', 'NBYTES_VERSION'), + }, + { + 'name': 'ncrypto', + 'description': 'Node.js cryptography helper library', + 'purl_base': 'pkg:generic/nodejs/ncrypto', + # ncrypto is maintained in this repository and released with Node.js. + 'version_fn': get_node_version, + }, + { + 'name': 'inspector_protocol', + 'description': 'Chrome DevTools Protocol (inspector) implementation', + 'purl_base': 'pkg:generic/chromium/inspector_protocol', + # Upstream is unversioned, so the vendored revision identifies the copy. + 'version_fn': lambda root: version_from_readme_node( + root / 'deps/inspector_protocol/README.node', 'Revision'), + }, + # --- Always-bundled ICU, JavaScript and Rust dependencies --- + { + 'name': 'icu', + 'description': 'International Components for Unicode', + 'purl_base': 'pkg:github/unicode-org/icu', + 'version_fn': lambda root: version_from_icu_dep( + root / 'tools/icu/current_ver.dep'), + }, + { + 'name': 'npm', + 'description': 'JavaScript package manager', + 'purl_base': 'pkg:npm/npm', + 'version_fn': lambda root: version_from_package_json( + root / 'deps/npm/package.json'), + }, + { + 'name': 'undici', + 'description': 'HTTP/1.1 client for Node.js', + 'purl_base': 'pkg:npm/undici', + # The manifest lives under src/, not at the top level of deps/undici. + 'version_fn': lambda root: version_from_package_json( + root / 'deps/undici/src/package.json'), + }, + { + 'name': 'acorn', + 'description': 'JavaScript AST parser, including acorn-walk', + 'purl_base': 'pkg:npm/acorn', + 'version_fn': lambda root: version_from_package_json( + root / 'deps/acorn/acorn/package.json'), + }, + { + 'name': 'amaro', + 'description': 'TypeScript type stripper for Node.js', + 'purl_base': 'pkg:npm/amaro', + 'scope': 'optional', + 'use_flag': 'node_use_amaro', + 'version_fn': lambda root: version_from_package_json( + root / 'deps/amaro/package.json'), + }, + { + 'name': 'corepack', + 'description': 'Package manager version management shim', + 'purl_base': 'pkg:npm/corepack', + 'version_fn': lambda root: version_from_package_json( + root / 'deps/corepack/package.json'), + }, + { + 'name': 'v8-crates', + 'description': 'Generated Cargo workspace of the Rust crates vendored ' + 'under deps/crates/vendor for the V8 build', + # Not published to crates.io, so this is not a pkg:cargo coordinate. + 'purl_base': 'pkg:generic/nodejs/node_crates', + 'use_flag': 'node_use_bundled_v8', + 'version_fn': lambda root: version_from_cargo_toml( + root / 'deps/crates/Cargo.toml'), + }, + # --- Dependencies that are never linked into the binary --- + { + 'name': 'googletest', + 'description': 'Google C++ testing and mocking framework, used by cctest', + 'purl_base': 'pkg:github/google/googletest', + 'scope': 'excluded', + # The vendored copy carries no version file. + 'version_fn': lambda root: UNKNOWN, + }, + { + 'name': 'postject', + 'description': 'Injects read-only resources into executables, used to ' + 'build single executable applications', + 'purl_base': 'pkg:npm/postject', + 'scope': 'excluded', + # deps/postject only holds the C header; the test fixture pins the version + # of the npm package that builds single executable applications. + 'version_fn': lambda root: version_from_package_json( + root / 'test/fixtures/postject-copy/node_modules/postject/package.json'), + }, +] + + +def resolve_scope(dep, config_vars): + """Return the CycloneDX scope for `dep` under the current build config.""" + scope = dep.get('scope', 'required') + if scope == 'excluded': + return scope + + # The feature was turned off at configure time. + use_flag = dep.get('use_flag') + if use_flag and config_vars.get(use_flag, 'true') == 'false': + return 'excluded' + + # A system-wide shared library is used instead of the bundled copy. + shared_flag = dep.get('shared_flag') + if shared_flag and config_vars.get(shared_flag, 'false') == 'true': + return 'excluded' + + for variable, value in dep.get('excluded_when', []): + if config_vars.get(variable) == value: + return 'excluded' + + return scope + + +def build_component(dep, version, scope): + return { + 'bom-ref': f'{dep["name"]}@{version}', + 'type': dep.get('component_type', 'library'), + 'name': dep['name'], + 'version': version, + 'description': dep['description'], + 'scope': scope, + 'purl': f'{dep["purl_base"]}@{version}', + } + + +def build_sbom(node_version, components, lib_files): + root_ref = f'nodejs@{node_version}' + root_component = { + 'bom-ref': root_ref, + 'type': 'framework', + 'name': 'nodejs', + 'version': node_version, + 'description': 'Node.js JavaScript runtime', + 'purl': f'pkg:github/nodejs/node@{node_version}', + } + + return { + '$schema': 'http://cyclonedx.org/schema/bom-1.5.schema.json', + 'bomFormat': 'CycloneDX', + 'specVersion': '1.5', + 'serialNumber': f'urn:uuid:{uuid.uuid4()}', + 'version': 1, + 'metadata': { + 'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(), + 'lifecycles': [{'phase': 'build'}], + # This script ships with Node.js, so it carries the Node.js version. + 'tools': [{ + 'vendor': 'Node.js', + 'name': 'generate_sbom.py', + 'version': node_version, + }], + 'component': root_component, + 'properties': [{ + 'name': 'nodejs:bundled-lib-files', + 'value': json.dumps(lib_files), + }], + }, + 'components': components, + 'dependencies': [ + # Only the components that end up in the binary are dependencies of the + # root component. None of them declare dependencies of their own. + { + 'ref': root_ref, + 'dependsOn': [c['bom-ref'] for c in components + if c['scope'] != 'excluded'], + }, + *({'ref': c['bom-ref'], 'dependsOn': []} for c in components), + ], + } + + +def validate_sbom(path): + """Check a generated SBOM for the fields consumers rely on.""" + if not path.exists(): + print(f'error: {path} not found. Run `make sbom` first.', file=sys.stderr) + sys.exit(1) + + try: + doc = json.loads(path.read_text(encoding='utf-8')) + except json.JSONDecodeError as exc: + print(f'error: {path} is not valid JSON: {exc}', file=sys.stderr) + sys.exit(1) + + failures = [] + + def check(condition, message): + if not condition: + failures.append(message) + + check(doc.get('bomFormat') == 'CycloneDX', + f'bomFormat: expected "CycloneDX", got {doc.get("bomFormat")!r}') + check(doc.get('specVersion') == '1.5', + f'specVersion: expected "1.5", got {doc.get("specVersion")!r}') + check(doc.get('components'), 'components: must be a non-empty list') + check(doc.get('metadata', {}).get('component', {}).get('name') == 'nodejs', + 'metadata.component.name: expected "nodejs"') + + for component in doc.get('components', []): + for field in ('type', 'name', 'version', 'purl'): + check(component.get(field), + f'component {component.get("name", "?")!r}: ' + f'missing or empty "{field}"') + + if failures: + print(f'SBOM validation FAILED ({len(failures)} issue(s)):', file=sys.stderr) + for failure in failures: + print(f' - {failure}', file=sys.stderr) + sys.exit(1) + + print(f'SBOM validation passed: {path}') + + +def generate_sbom(config_vars, node_version): + components = [] + for dep in DEP_CATALOG: + scope = resolve_scope(dep, config_vars) + try: + version = dep['version_fn'](REPO_ROOT) + except Exception as exc: + # A dependency we cannot read is worth a warning, not a failed build. + if scope != 'excluded': + print(f'warning: cannot determine {dep["name"]} version: {exc}', + file=sys.stderr) + version = UNKNOWN + components.append(build_component(dep, version, scope)) + + return build_sbom(node_version, components, + config_vars.get('node_library_files', [])) + + +def main(): + parser = argparse.ArgumentParser( + description='Generate or validate a CycloneDX 1.5 SBOM for Node.js.') + parser.add_argument('--output', type=Path, + help='path of the SBOM JSON file ' + '(default: out/Release/node-v{version}-sbom.json)') + parser.add_argument('--validate', action='store_true', + help='validate an existing SBOM instead of generating ' + 'a new one') + args = parser.parse_args() + + node_version = get_node_version(REPO_ROOT) + output_path = args.output or ( + REPO_ROOT / 'out/Release' / f'node-v{node_version}-sbom.json') + + if args.validate: + validate_sbom(output_path) + return + + config_vars = parse_config_gypi(REPO_ROOT / 'config.gypi') + doc = generate_sbom(config_vars, node_version) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(doc, indent=2) + '\n', encoding='utf-8') + print(f'SBOM written to {output_path}') + + +if __name__ == '__main__': + main() From 7221ccdee4d7320edfd9704c38b74e8972131d1e Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Fri, 16 Oct 2026 22:31:55 +0100 Subject: [PATCH 02/13] tools: omit build-excluded dependencies from SBOM Previously, `generate_sbom.py` emitted components for dependencies that were not linked into the binary, marking them with the `excluded` scope. Drop those components so the SBOM only lists what ships in the build. Add `excluded_when` conditions to catalog entries whose inclusion depends on configure flags: * perfetto: only included with `--with-perfetto` * inspector_protocol: omitted when the inspector is disabled * icu: omitted with `--with-intl=none` * npm: omitted with `--without-npm` * corepack: only included with `--with-corepack` * node_crates: omitted when Temporal support is disabled or when `--shared-temporal_capi` is used Mark icu, corepack and node_crates as `optional` since they are not always bundled. Skip components that resolve to `excluded` in `generate_sbom()` and drop the now-redundant `excluded` filtering when building the root component's `dependsOn` list and when warning about unreadable versions. Signed-off-by: Beth Griggs Assisted-by: IBM Bob --- tools/generate_sbom.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/tools/generate_sbom.py b/tools/generate_sbom.py index af1a87c0f03e..a5200702bcb1 100644 --- a/tools/generate_sbom.py +++ b/tools/generate_sbom.py @@ -326,6 +326,8 @@ def version_from_openssl(root): 'description': 'Performance instrumentation and tracing SDK', 'purl_base': 'pkg:github/google/perfetto', 'scope': 'optional', + # Included only with --with-perfetto; omitted by default. + 'excluded_when': [('v8_use_perfetto', 0)], 'shared_flag': 'node_shared_perfetto', 'version_fn': lambda root: version_from_plain_file( root / 'deps/perfetto/VERSION'), @@ -380,14 +382,19 @@ def version_from_openssl(root): 'description': 'Chrome DevTools Protocol (inspector) implementation', 'purl_base': 'pkg:generic/chromium/inspector_protocol', # Upstream is unversioned, so the vendored revision identifies the copy. + # Omitted with --without-inspector or --without-ssl. + 'excluded_when': [('v8_enable_inspector', 0)], 'version_fn': lambda root: version_from_readme_node( root / 'deps/inspector_protocol/README.node', 'Revision'), }, - # --- Always-bundled ICU, JavaScript and Rust dependencies --- + # --- ICU, JavaScript and Rust dependencies --- { 'name': 'icu', 'description': 'International Components for Unicode', 'purl_base': 'pkg:github/unicode-org/icu', + # Omitted when built with --with-intl=none. + 'scope': 'optional', + 'excluded_when': [('v8_enable_i18n_support', 0)], 'version_fn': lambda root: version_from_icu_dep( root / 'tools/icu/current_ver.dep'), }, @@ -395,6 +402,8 @@ def version_from_openssl(root): 'name': 'npm', 'description': 'JavaScript package manager', 'purl_base': 'pkg:npm/npm', + # Omitted when built with --without-npm. + 'excluded_when': [('node_install_npm', 'false')], 'version_fn': lambda root: version_from_package_json( root / 'deps/npm/package.json'), }, @@ -426,6 +435,9 @@ def version_from_openssl(root): 'name': 'corepack', 'description': 'Package manager version management shim', 'purl_base': 'pkg:npm/corepack', + # Not bundled by default; included only with --with-corepack. + 'scope': 'optional', + 'excluded_when': [('node_install_corepack', 'false')], 'version_fn': lambda root: version_from_package_json( root / 'deps/corepack/package.json'), }, @@ -435,7 +447,13 @@ def version_from_openssl(root): 'under deps/crates/vendor for the V8 build', # Not published to crates.io, so this is not a pkg:cargo coordinate. 'purl_base': 'pkg:generic/nodejs/node_crates', + # Omitted with --v8-disable-temporal-support or when no Rust compiler is + # present (configure auto-disables Temporal in that case), or when + # --shared-temporal_capi links against a system-provided library. + 'scope': 'optional', 'use_flag': 'node_use_bundled_v8', + 'shared_flag': 'node_shared_temporal_capi', + 'excluded_when': [('v8_enable_temporal_support', 0)], 'version_fn': lambda root: version_from_cargo_toml( root / 'deps/crates/Cargo.toml'), }, @@ -531,12 +549,11 @@ def build_sbom(node_version, components, lib_files): }, 'components': components, 'dependencies': [ - # Only the components that end up in the binary are dependencies of the - # root component. None of them declare dependencies of their own. + # All components in the list are linked into the binary; excluded + # deps are omitted before reaching here. None declare sub-dependencies. { 'ref': root_ref, - 'dependsOn': [c['bom-ref'] for c in components - if c['scope'] != 'excluded'], + 'dependsOn': [c['bom-ref'] for c in components], }, *({'ref': c['bom-ref'], 'dependsOn': []} for c in components), ], @@ -588,13 +605,14 @@ def generate_sbom(config_vars, node_version): components = [] for dep in DEP_CATALOG: scope = resolve_scope(dep, config_vars) + if scope == 'excluded': + continue try: version = dep['version_fn'](REPO_ROOT) except Exception as exc: # A dependency we cannot read is worth a warning, not a failed build. - if scope != 'excluded': - print(f'warning: cannot determine {dep["name"]} version: {exc}', - file=sys.stderr) + print(f'warning: cannot determine {dep["name"]} version: {exc}', + file=sys.stderr) version = UNKNOWN components.append(build_component(dep, version, scope)) From e85480c877432b786870e33a023877cac7080359 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Mon, 19 Oct 2026 16:09:19 +0100 Subject: [PATCH 03/13] tools, build: update to CycloneDX 1.7 format Signed-off-by: Beth Griggs --- test/tools/test_generate_sbom.py | 2 +- tools/generate_sbom.py | 25 ++++++++++++++----------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/test/tools/test_generate_sbom.py b/test/tools/test_generate_sbom.py index 5410a96dc43a..fa3288daa160 100644 --- a/test/tools/test_generate_sbom.py +++ b/test/tools/test_generate_sbom.py @@ -224,7 +224,7 @@ def _component(self, name, version, scope='required'): def test_structure(self): doc = sbom.build_sbom('27.0.0', [self._component('zlib', '1.3.0')], []) self.assertEqual(doc['bomFormat'], 'CycloneDX') - self.assertEqual(doc['specVersion'], '1.5') + self.assertEqual(doc['specVersion'], '1.7') self.assertTrue(doc['serialNumber'].startswith('urn:uuid:')) self.assertEqual(doc['metadata']['component']['name'], 'nodejs') self.assertEqual(doc['metadata']['component']['version'], '27.0.0') diff --git a/tools/generate_sbom.py b/tools/generate_sbom.py index a5200702bcb1..3c7e8f0167f6 100644 --- a/tools/generate_sbom.py +++ b/tools/generate_sbom.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -# Generates a CycloneDX 1.5 Software Bill of Materials (SBOM) describing the +# Generates a CycloneDX 1.7 Software Bill of Materials (SBOM) describing the # bundled runtime dependencies of the Node.js binary. # # Usage: @@ -527,20 +527,23 @@ def build_sbom(node_version, components, lib_files): } return { - '$schema': 'http://cyclonedx.org/schema/bom-1.5.schema.json', + '$schema': 'http://cyclonedx.org/schema/bom-1.7.schema.json', 'bomFormat': 'CycloneDX', - 'specVersion': '1.5', + 'specVersion': '1.7', 'serialNumber': f'urn:uuid:{uuid.uuid4()}', 'version': 1, 'metadata': { 'timestamp': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'lifecycles': [{'phase': 'build'}], # This script ships with Node.js, so it carries the Node.js version. - 'tools': [{ - 'vendor': 'Node.js', - 'name': 'generate_sbom.py', - 'version': node_version, - }], + 'tools': { + 'components': [{ + 'type': 'application', + 'authors': [{'name': 'Node.js'}], + 'name': 'generate_sbom.py', + 'version': node_version, + }], + }, 'component': root_component, 'properties': [{ 'name': 'nodejs:bundled-lib-files', @@ -580,8 +583,8 @@ def check(condition, message): check(doc.get('bomFormat') == 'CycloneDX', f'bomFormat: expected "CycloneDX", got {doc.get("bomFormat")!r}') - check(doc.get('specVersion') == '1.5', - f'specVersion: expected "1.5", got {doc.get("specVersion")!r}') + check(doc.get('specVersion') == '1.7', + f'specVersion: expected "1.7", got {doc.get("specVersion")!r}') check(doc.get('components'), 'components: must be a non-empty list') check(doc.get('metadata', {}).get('component', {}).get('name') == 'nodejs', 'metadata.component.name: expected "nodejs"') @@ -622,7 +625,7 @@ def generate_sbom(config_vars, node_version): def main(): parser = argparse.ArgumentParser( - description='Generate or validate a CycloneDX 1.5 SBOM for Node.js.') + description='Generate or validate a CycloneDX 1.7 SBOM for Node.js.') parser.add_argument('--output', type=Path, help='path of the SBOM JSON file ' '(default: out/Release/node-v{version}-sbom.json)') From 5cec17eed7de90d36dc77118484079079e56c3ac Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Mon, 19 Oct 2026 16:45:38 +0100 Subject: [PATCH 04/13] tools: update PURL to use `generic/nodejs/node` Signed-off-by: Beth Griggs --- tools/generate_sbom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/generate_sbom.py b/tools/generate_sbom.py index 3c7e8f0167f6..941b0c69906b 100644 --- a/tools/generate_sbom.py +++ b/tools/generate_sbom.py @@ -523,7 +523,7 @@ def build_sbom(node_version, components, lib_files): 'name': 'nodejs', 'version': node_version, 'description': 'Node.js JavaScript runtime', - 'purl': f'pkg:github/nodejs/node@{node_version}', + 'purl': f'pkg:generic/nodejs/node@{node_version}', } return { From 3d01644c99b45ec8e7b7e69448154daf072e6d4e Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 12:32:31 +0000 Subject: [PATCH 05/13] tools: create and move to tools/sbom/ Signed-off-by: Beth Griggs --- Makefile | 4 ++-- doc/contributing/maintaining/maintaining-dependencies.md | 4 ++-- tools/{ => sbom}/generate_sbom.py | 6 +++--- {test/tools => tools/sbom}/test_generate_sbom.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) rename tools/{ => sbom}/generate_sbom.py (99%) rename {test/tools => tools/sbom}/test_generate_sbom.py (99%) diff --git a/Makefile b/Makefile index 609f769be03a..6c44385a48d8 100644 --- a/Makefile +++ b/Makefile @@ -876,11 +876,11 @@ VERSION=v$(RAWVER) .PHONY: sbom sbom: config.gypi ## Write a CycloneDX SBOM of the bundled dependencies to out/Release/node-$(VERSION)-sbom.json. - $(PYTHON) tools/generate_sbom.py + $(PYTHON) tools/sbom/generate_sbom.py .PHONY: test-sbom test-sbom: ## Validate the SBOM written by `make sbom`. - $(PYTHON) tools/generate_sbom.py --validate + $(PYTHON) tools/sbom/generate_sbom.py --validate .PHONY: doc-only .NOTPARALLEL: doc-only diff --git a/doc/contributing/maintaining/maintaining-dependencies.md b/doc/contributing/maintaining/maintaining-dependencies.md index f28e06f7f1e8..a8ec6af248c7 100644 --- a/doc/contributing/maintaining/maintaining-dependencies.md +++ b/doc/contributing/maintaining/maintaining-dependencies.md @@ -106,7 +106,7 @@ shared library is available can added by: }], ``` -* add an entry to the `DEP_CATALOG` list in `tools/generate_sbom.py` so the +* add an entry to the `DEP_CATALOG` list in `tools/sbom/generate_sbom.py` so the dependency appears in the generated SBOM. Set `shared_flag` to the `node_shared_depname` variable and `version_fn` to an extractor for whichever file in the dependency records its version. See the comment @@ -152,7 +152,7 @@ can be added as a non-externalizable dependency. In this case simply add the path to the JavaScript file in the `deps_files` list in the `node.gyp` file. -Also add an entry to the `DEP_CATALOG` list in `tools/generate_sbom.py`, with +Also add an entry to the `DEP_CATALOG` list in `tools/sbom/generate_sbom.py`, with `version_fn` reading the dependency's `package.json`, so the dependency appears in the generated SBOM. diff --git a/tools/generate_sbom.py b/tools/sbom/generate_sbom.py similarity index 99% rename from tools/generate_sbom.py rename to tools/sbom/generate_sbom.py index 941b0c69906b..a72f972dfbf7 100644 --- a/tools/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -3,8 +3,8 @@ # bundled runtime dependencies of the Node.js binary. # # Usage: -# python tools/generate_sbom.py [--output PATH] -# python tools/generate_sbom.py --validate [--output PATH] +# python tools/sbom/generate_sbom.py [--output PATH] +# python tools/sbom/generate_sbom.py --validate [--output PATH] # # The output defaults to out/Release/node-v{version}-sbom.json. Generating # requires ./configure to have been run first, as the set of dependencies that @@ -19,7 +19,7 @@ import uuid from pathlib import Path -REPO_ROOT = Path(__file__).parent.parent +REPO_ROOT = Path(__file__).parent.parent.parent # Used when a dependency does not record its version anywhere we can read. UNKNOWN = 'unknown' diff --git a/test/tools/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py similarity index 99% rename from test/tools/test_generate_sbom.py rename to tools/sbom/test_generate_sbom.py index fa3288daa160..c0fb92ea683c 100644 --- a/test/tools/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -6,7 +6,7 @@ from os import path ROOT = path.abspath(path.join(path.dirname(__file__), '..', '..')) -sys.path.insert(0, path.join(ROOT, 'tools')) +sys.path.insert(0, path.join(ROOT, 'tools', 'sbom')) import generate_sbom as sbom From e9afc118c71cc3394efe1b9714234266cea9fe30 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 11:38:46 +0000 Subject: [PATCH 06/13] tools: record build environment in SBOM Add the compiler versions (CC, CXX, rustc, cargo), the git commit SHA and V8's embedder string to the generated SBOM, so it shows what toolchain and source produced the binary. Anything that can't be determined, such as a missing tool or a source tarball with no git checkout, is recorded as "N/A" instead of failing generation. Assisted-by: IBM Bob Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 160 ++++++++++++++++++- tools/sbom/test_generate_sbom.py | 253 ++++++++++++++++++++++++++++++- 2 files changed, 404 insertions(+), 9 deletions(-) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index a72f972dfbf7..83caaacb0800 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -14,7 +14,10 @@ import datetime import glob import json +import os import re +import shlex +import subprocess import sys import uuid from pathlib import Path @@ -24,6 +27,9 @@ # Used when a dependency does not record its version anywhere we can read. UNKNOWN = 'unknown' +# Used when a build-environment fact cannot be determined. +NOT_AVAILABLE = 'N/A' + def parse_config_gypi(path): """Read config.gypi and return its `variables` dict.""" @@ -46,6 +52,104 @@ def get_node_version(root): return '.'.join(parts) +# --------------------------------------------------------------------------- +# Build environment +# +# Facts about the machine and toolchain that produced the binary. Like the +# version extractors below, they never raise: anything that cannot be +# determined is recorded as NOT_AVAILABLE so one failed probe never prevents +# the SBOM from being generated. +# --------------------------------------------------------------------------- + +def _run(cmd_args, timeout=10): + """Run `cmd_args` (a list, never a shell string) and return its stdout. + + Returns None if the command is missing, fails, times out or prints nothing. + """ + try: + result = subprocess.run(cmd_args, capture_output=True, text=True, + timeout=timeout, check=False) + except (OSError, ValueError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + return result.stdout.strip() or None + + +def get_compiler_version(env_var, default): + """Report the tool named by `$env_var` (or `default`) and its version. + + Returns {'command': str, 'output': str} where `output` is the first line of + ` --version`, or NOT_AVAILABLE if the tool cannot be run. The variable + is split with shlex because it may hold a wrapper or flags (`ccache gcc`). + """ + command = os.environ.get(env_var) or default + try: + args = shlex.split(command) + except ValueError: + return {'command': command, 'output': NOT_AVAILABLE} + output = _run(args + ['--version']) if args else None + first_line = output.splitlines()[0].strip() if output else '' + return {'command': command, 'output': first_line or NOT_AVAILABLE} + + +def _parse_version(output): + """Pull a dotted version number out of `output`, else return it as is.""" + match = re.search(r'\d+(?:\.\d+)+', output) + return match.group(0) if match else output + + +def get_git_sha(root): + """Return the commit `root` is checked out at, or NOT_AVAILABLE. + + Source tarballs are not git checkouts, and git may not be installed. + """ + output = _run(['git', '-C', str(root), 'rev-parse', 'HEAD']) + # SHA-1 repositories give 40 hex digits, SHA-256 repositories give 64. + if output and re.fullmatch(r'[0-9a-f]{40}|[0-9a-f]{64}', output): + return output + return NOT_AVAILABLE + + +def get_v8_embedder_string(root): + """Return V8's embedder string from common.gypi, or NOT_AVAILABLE. + + It is a gyp variable that does not reach config.gypi, so it is read from + the source. It identifies the set of V8 patches applied on top of upstream. + """ + value = _search(Path(root) / 'common.gypi', + r"'v8_embedder_string':\s*'([^']*)'") + return NOT_AVAILABLE if value in (UNKNOWN, '') else value + + +def collect_build_environment(root, config_vars): + """Return a dict of build-environment facts for build_sbom(). + + Each fact is a string, with NOT_AVAILABLE when it cannot be determined. + """ + # Same defaults as configure.py, so an unset CC/CXX reports what configure + # actually used. + if sys.platform == 'darwin': + default_cc, default_cxx = 'cc', 'c++' + elif sys.platform == 'win32': + default_cc, default_cxx = 'clang', 'clang' + else: + default_cc, default_cxx = 'gcc', 'g++' + + compilers = [] + for name, env_var, default in (('cc', 'CC', default_cc), + ('cxx', 'CXX', default_cxx), + ('rustc', 'RUSTC', 'rustc'), + ('cargo', 'CARGO', 'cargo')): + compilers.append({'name': name, + **get_compiler_version(env_var, default)}) + return { + 'compilers': compilers, + 'git_sha': get_git_sha(root), + 'v8_embedder_string': get_v8_embedder_string(root), + } + + # --------------------------------------------------------------------------- # Version extractors # @@ -515,7 +619,10 @@ def build_component(dep, version, scope): } -def build_sbom(node_version, components, lib_files): +def build_sbom(node_version, components, lib_files, build_env=None): + # `build_env` is the dict from collect_build_environment(); fields missing + # from it are left out of the document. + build_env = build_env or {} root_ref = f'nodejs@{node_version}' root_component = { 'bom-ref': root_ref, @@ -526,7 +633,34 @@ def build_sbom(node_version, components, lib_files): 'purl': f'pkg:generic/nodejs/node@{node_version}', } - return { + properties = [{ + 'name': 'nodejs:bundled-lib-files', + 'value': json.dumps(lib_files), + }] + # The raw version line is kept as a property so nothing is lost when the + # parsed component version is imprecise. + compilers = build_env.get('compilers', []) + properties.extend({ + 'name': f'nodejs:{c["name"]}-version', + 'value': c['output'], + } for c in compilers) + + git_sha = build_env.get('git_sha') + if git_sha: + properties.append({'name': 'nodejs:git-sha', 'value': git_sha}) + if git_sha != NOT_AVAILABLE: + # Pins the exact source tree, which the purl version alone does not. + root_component['externalReferences'] = [{ + 'type': 'vcs', + 'url': 'https://github.com/nodejs/node', + 'comment': f'commit {git_sha}', + }] + v8_embedder_string = build_env.get('v8_embedder_string') + if v8_embedder_string: + properties.append({'name': 'nodejs:v8-embedder-string', + 'value': v8_embedder_string}) + + sbom = { '$schema': 'http://cyclonedx.org/schema/bom-1.7.schema.json', 'bomFormat': 'CycloneDX', 'specVersion': '1.7', @@ -545,10 +679,7 @@ def build_sbom(node_version, components, lib_files): }], }, 'component': root_component, - 'properties': [{ - 'name': 'nodejs:bundled-lib-files', - 'value': json.dumps(lib_files), - }], + 'properties': properties, }, 'components': components, 'dependencies': [ @@ -562,6 +693,20 @@ def build_sbom(node_version, components, lib_files): ], } + if compilers: + sbom['formulation'] = [{ + 'bom-ref': 'build-environment', + 'components': [{ + 'bom-ref': f'build-environment:{c["name"]}', + 'type': 'application', + 'name': c['name'], + 'version': (c['output'] if c['output'] == NOT_AVAILABLE + else _parse_version(c['output'])), + 'description': f'Build tool from `{c["command"]}`', + } for c in compilers], + }] + return sbom + def validate_sbom(path): """Check a generated SBOM for the fields consumers rely on.""" @@ -620,7 +765,8 @@ def generate_sbom(config_vars, node_version): components.append(build_component(dep, version, scope)) return build_sbom(node_version, components, - config_vars.get('node_library_files', [])) + config_vars.get('node_library_files', []), + collect_build_environment(REPO_ROOT, config_vars)) def main(): diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py index c0fb92ea683c..b210b38c02c4 100644 --- a/tools/sbom/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -2,7 +2,9 @@ import io import os import sys +import tempfile import unittest +from unittest import mock from os import path ROOT = path.abspath(path.join(path.dirname(__file__), '..', '..')) @@ -230,20 +232,267 @@ def test_structure(self): self.assertEqual(doc['metadata']['component']['version'], '27.0.0') self.assertEqual(len(doc['components']), 1) - def test_excluded_components_are_not_root_dependencies(self): + def test_root_depends_on_all_given_components(self): components = [ self._component('zlib', '1.3.0'), - self._component('googletest', 'unknown', scope='excluded'), + self._component('openssl', '3.0.0', scope='optional'), ] doc = sbom.build_sbom('27.0.0', components, []) root = doc['dependencies'][0] self.assertEqual(root['ref'], 'nodejs@27.0.0') + self.assertEqual(root['dependsOn'], ['zlib@1.3.0', 'openssl@3.0.0']) + + def test_generate_omits_excluded_components(self): + # Excluded deps are filtered out by generate_sbom() before build_sbom(). + catalog = [ + {'name': 'zlib', 'description': '', 'purl_base': 'pkg:generic/zlib', + 'version_fn': lambda root: '1.3.0'}, + {'name': 'googletest', 'description': '', + 'purl_base': 'pkg:generic/googletest', 'scope': 'excluded', + 'version_fn': lambda root: 'unknown'}, + ] + with mock.patch.object(sbom, 'DEP_CATALOG', catalog): + doc = sbom.generate_sbom({}, '27.0.0') + self.assertEqual([c['name'] for c in doc['components']], ['zlib']) + root = doc['dependencies'][0] + self.assertEqual(root['ref'], 'nodejs@27.0.0') self.assertEqual(root['dependsOn'], ['zlib@1.3.0']) def test_serial_number_is_unique(self): self.assertNotEqual(sbom.build_sbom('27.0.0', [], [])['serialNumber'], sbom.build_sbom('27.0.0', [], [])['serialNumber']) + def test_build_env_is_optional(self): + # An empty build_env adds nothing to the document. + without = sbom.build_sbom('27.0.0', [], []) + with_env = sbom.build_sbom('27.0.0', [], [], {}) + for doc in (without, with_env): + del doc['serialNumber'] + del doc['metadata']['timestamp'] + self.assertEqual(without, with_env) + + def test_compilers_are_emitted(self): + build_env = {'compilers': [ + {'name': 'cc', 'command': 'ccache gcc', + 'output': 'gcc (GCC) 13.2.1 20240101'}, + {'name': 'rustc', 'command': 'rustc', 'output': sbom.NOT_AVAILABLE}, + ]} + doc = sbom.build_sbom('27.0.0', [], [], build_env) + props = {p['name']: p['value'] for p in doc['metadata']['properties']} + self.assertEqual(props['nodejs:cc-version'], 'gcc (GCC) 13.2.1 20240101') + self.assertEqual(props['nodejs:rustc-version'], 'N/A') + components = doc['formulation'][0]['components'] + self.assertEqual([(c['name'], c['version']) for c in components], + [('cc', '13.2.1'), ('rustc', 'N/A')]) + self.assertEqual(components[0]['type'], 'application') + self.assertIn('ccache gcc', components[0]['description']) + + def test_git_sha_and_embedder_string_are_emitted(self): + sha = 'a' * 40 + doc = sbom.build_sbom('27.0.0', [], [], + {'git_sha': sha, 'v8_embedder_string': '-node.15'}) + props = {p['name']: p['value'] for p in doc['metadata']['properties']} + self.assertEqual(props['nodejs:git-sha'], sha) + self.assertEqual(props['nodejs:v8-embedder-string'], '-node.15') + refs = doc['metadata']['component']['externalReferences'] + self.assertEqual(refs, [{ + 'type': 'vcs', + 'url': 'https://github.com/nodejs/node', + 'comment': 'commit ' + sha, + }]) + + def test_unknown_git_sha_omits_vcs_reference(self): + doc = sbom.build_sbom('27.0.0', [], [], + {'git_sha': sbom.NOT_AVAILABLE, + 'v8_embedder_string': sbom.NOT_AVAILABLE}) + props = {p['name']: p['value'] for p in doc['metadata']['properties']} + self.assertEqual(props['nodejs:git-sha'], 'N/A') + self.assertEqual(props['nodejs:v8-embedder-string'], 'N/A') + self.assertNotIn('externalReferences', doc['metadata']['component']) + + def test_no_git_properties_without_build_env(self): + doc = sbom.build_sbom('27.0.0', [], []) + names = [p['name'] for p in doc['metadata']['properties']] + self.assertEqual(names, ['nodejs:bundled-lib-files']) + self.assertNotIn('externalReferences', doc['metadata']['component']) + + def test_no_formulation_without_build_env(self): + self.assertNotIn('formulation', sbom.build_sbom('27.0.0', [], [])) + + +class TestRun(unittest.TestCase): + + def test_returns_stdout(self): + self.assertEqual(sbom._run([sys.executable, '-c', 'print("hi")']), 'hi') + + def test_missing_binary_returns_none(self): + self.assertIsNone(sbom._run(['this-binary-does-not-exist-sbom'])) + + def test_nonzero_exit_returns_none(self): + self.assertIsNone(sbom._run([sys.executable, '-c', 'raise SystemExit(1)'])) + + def test_empty_output_returns_none(self): + self.assertIsNone(sbom._run([sys.executable, '-c', 'pass'])) + + def test_timeout_returns_none(self): + self.assertIsNone(sbom._run( + [sys.executable, '-c', 'import time; time.sleep(5)'], timeout=0.1)) + + +class TestGetCompilerVersion(unittest.TestCase): + + def test_set_and_working(self): + with mock.patch.dict(os.environ, {'CC': 'gcc'}), \ + mock.patch.object(sbom, '_run', + return_value='gcc 13.2.1\nCopyright') as run: + result = sbom.get_compiler_version('CC', 'cc') + run.assert_called_once_with(['gcc', '--version']) + self.assertEqual(result, {'command': 'gcc', 'output': 'gcc 13.2.1'}) + + def test_unset_uses_default(self): + env = {k: v for k, v in os.environ.items() if k != 'CC'} + with mock.patch.dict(os.environ, env, clear=True), \ + mock.patch.object(sbom, '_run', return_value='cc 1.0') as run: + result = sbom.get_compiler_version('CC', 'cc') + run.assert_called_once_with(['cc', '--version']) + self.assertEqual(result['command'], 'cc') + self.assertEqual(result['output'], 'cc 1.0') + + def test_empty_variable_uses_default(self): + with mock.patch.dict(os.environ, {'CC': ''}), \ + mock.patch.object(sbom, '_run', return_value='cc 1.0') as run: + sbom.get_compiler_version('CC', 'cc') + run.assert_called_once_with(['cc', '--version']) + + def test_missing_binary_is_not_available(self): + with mock.patch.dict(os.environ, {'CC': 'nonexistent-cc'}), \ + mock.patch.object(sbom, '_run', return_value=None): + result = sbom.get_compiler_version('CC', 'cc') + self.assertEqual(result, + {'command': 'nonexistent-cc', 'output': sbom.NOT_AVAILABLE}) + + def test_wrapper_and_flags_are_split(self): + with mock.patch.dict(os.environ, {'CC': 'ccache gcc -m64'}), \ + mock.patch.object(sbom, '_run', return_value='gcc 13') as run: + result = sbom.get_compiler_version('CC', 'cc') + run.assert_called_once_with(['ccache', 'gcc', '-m64', '--version']) + self.assertEqual(result['command'], 'ccache gcc -m64') + + def test_unbalanced_quotes_are_not_available(self): + with mock.patch.dict(os.environ, {'CC': 'gcc "unterminated'}), \ + mock.patch.object(sbom, '_run') as run: + result = sbom.get_compiler_version('CC', 'cc') + run.assert_not_called() + self.assertEqual(result['output'], sbom.NOT_AVAILABLE) + + +class TestGetGitSha(unittest.TestCase): + + def test_valid_sha1(self): + sha = '0123456789abcdef' * 2 + '01234567' + with mock.patch.object(sbom, '_run', return_value=sha) as run: + self.assertEqual(sbom.get_git_sha('/some/root'), sha) + run.assert_called_once_with(['git', '-C', '/some/root', 'rev-parse', 'HEAD']) + + def test_valid_sha256(self): + sha = 'ab' * 32 + with mock.patch.object(sbom, '_run', return_value=sha): + self.assertEqual(sbom.get_git_sha('.'), sha) + + def test_garbage_is_not_available(self): + for output in ('HEAD', 'fatal: not a git repository', 'ABC' * 14, + 'g' * 40, 'a' * 39, 'a' * 41, '0' * 40 + '\n' + '0' * 40): + with mock.patch.object(sbom, '_run', return_value=output): + self.assertEqual(sbom.get_git_sha('.'), sbom.NOT_AVAILABLE, output) + + def test_git_failure_is_not_available(self): + with mock.patch.object(sbom, '_run', return_value=None): + self.assertEqual(sbom.get_git_sha('.'), sbom.NOT_AVAILABLE) + + def test_outside_a_repository(self): + with tempfile.TemporaryDirectory() as tmp: + # Stop git searching parent directories for a repository. + with mock.patch.dict(os.environ, {'GIT_CEILING_DIRECTORIES': tmp}): + self.assertEqual(sbom.get_git_sha(tmp), sbom.NOT_AVAILABLE) + + +class TestGetV8EmbedderString(unittest.TestCase): + + def test_read_from_real_common_gypi(self): + value = sbom.get_v8_embedder_string(sbom.REPO_ROOT) + self.assertRegex(value, r'^-node\.\d+$') + + def _read(self, content): + with tempfile.TemporaryDirectory() as tmp: + with open(path.join(tmp, 'common.gypi'), 'w', encoding='utf-8') as f: + f.write(content) + with contextlib.redirect_stderr(io.StringIO()): + return sbom.get_v8_embedder_string(tmp) + + def test_custom_value(self): + self.assertEqual(self._read("{'v8_embedder_string': '-custom.2',}"), + '-custom.2') + + def test_missing_variable_is_not_available(self): + self.assertEqual(self._read("{'other': 'x'}"), sbom.NOT_AVAILABLE) + + def test_empty_value_is_not_available(self): + self.assertEqual(self._read("{'v8_embedder_string': '',}"), + sbom.NOT_AVAILABLE) + + def test_missing_file_is_not_available(self): + with tempfile.TemporaryDirectory() as tmp: + with contextlib.redirect_stderr(io.StringIO()): + self.assertEqual(sbom.get_v8_embedder_string(tmp), sbom.NOT_AVAILABLE) + + +class TestParseVersion(unittest.TestCase): + + def test_extracts_dotted_version(self): + self.assertEqual(sbom._parse_version('gcc (GCC) 13.2.1 2024'), '13.2.1') + self.assertEqual(sbom._parse_version('rustc 1.85.0 (abc 2025-01-01)'), + '1.85.0') + + def test_falls_back_to_input(self): + self.assertEqual(sbom._parse_version('mystery tool'), 'mystery tool') + + +class TestCollectBuildEnvironment(unittest.TestCase): + + def test_reports_all_tools(self): + with mock.patch.object(sbom, '_run', return_value='tool 1.2.3'): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + self.assertEqual([c['name'] for c in env['compilers']], + ['cc', 'cxx', 'rustc', 'cargo']) + self.assertTrue(all(c['output'] == 'tool 1.2.3' + for c in env['compilers'])) + + def test_reports_git_sha_and_embedder_string(self): + sha = 'c' * 40 + with mock.patch.object(sbom, '_run', return_value=sha): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + self.assertEqual(env['git_sha'], sha) + self.assertRegex(env['v8_embedder_string'], r'^-node\.\d+$') + + def test_all_tools_missing(self): + with mock.patch.object(sbom, '_run', return_value=None): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + self.assertTrue(all(c['output'] == sbom.NOT_AVAILABLE + for c in env['compilers'])) + + def test_platform_defaults(self): + for platform, cc, cxx in (('darwin', 'cc', 'c++'), + ('win32', 'clang', 'clang'), + ('linux', 'gcc', 'g++')): + env = {k: v for k, v in os.environ.items() + if k not in ('CC', 'CXX')} + with mock.patch.dict(os.environ, env, clear=True), \ + mock.patch.object(sys, 'platform', platform), \ + mock.patch.object(sbom, '_run', return_value='x 1.0'): + result = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + commands = {c['name']: c['command'] for c in result['compilers']} + self.assertEqual((commands['cc'], commands['cxx']), (cc, cxx), platform) + if __name__ == '__main__': unittest.main() From ff2e6049e1fe798afcc31c4d9309d5b0eff06e36 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 11:47:03 +0000 Subject: [PATCH 07/13] tools: record builder hostname in SBOM Add a nodejs:builder property to the generated SBOM with the hostname of the build machine, or N/A if it can't be determined. Assisted-by: IBM Bob Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 13 ++++++++++++ tools/sbom/test_generate_sbom.py | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index 83caaacb0800..4d4920ac5fde 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -17,6 +17,7 @@ import os import re import shlex +import socket import subprocess import sys import uuid @@ -122,6 +123,14 @@ def get_v8_embedder_string(root): return NOT_AVAILABLE if value in (UNKNOWN, '') else value +def get_builder_name(): + """Return the hostname of the machine doing the build, or NOT_AVAILABLE.""" + try: + return socket.gethostname().strip() or NOT_AVAILABLE + except OSError: + return NOT_AVAILABLE + + def collect_build_environment(root, config_vars): """Return a dict of build-environment facts for build_sbom(). @@ -147,6 +156,7 @@ def collect_build_environment(root, config_vars): 'compilers': compilers, 'git_sha': get_git_sha(root), 'v8_embedder_string': get_v8_embedder_string(root), + 'builder': get_builder_name(), } @@ -659,6 +669,9 @@ def build_sbom(node_version, components, lib_files, build_env=None): if v8_embedder_string: properties.append({'name': 'nodejs:v8-embedder-string', 'value': v8_embedder_string}) + builder = build_env.get('builder') + if builder: + properties.append({'name': 'nodejs:builder', 'value': builder}) sbom = { '$schema': 'http://cyclonedx.org/schema/bom-1.7.schema.json', diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py index b210b38c02c4..af7354b32563 100644 --- a/tools/sbom/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -310,6 +310,16 @@ def test_unknown_git_sha_omits_vcs_reference(self): self.assertEqual(props['nodejs:v8-embedder-string'], 'N/A') self.assertNotIn('externalReferences', doc['metadata']['component']) + def test_builder_is_emitted(self): + doc = sbom.build_sbom('27.0.0', [], [], {'builder': 'build-host-1'}) + props = {p['name']: p['value'] for p in doc['metadata']['properties']} + self.assertEqual(props['nodejs:builder'], 'build-host-1') + + def test_unknown_builder_is_emitted_as_not_available(self): + doc = sbom.build_sbom('27.0.0', [], [], {'builder': sbom.NOT_AVAILABLE}) + props = {p['name']: p['value'] for p in doc['metadata']['properties']} + self.assertEqual(props['nodejs:builder'], 'N/A') + def test_no_git_properties_without_build_env(self): doc = sbom.build_sbom('27.0.0', [], []) names = [p['name'] for p in doc['metadata']['properties']] @@ -446,6 +456,26 @@ def test_missing_file_is_not_available(self): self.assertEqual(sbom.get_v8_embedder_string(tmp), sbom.NOT_AVAILABLE) +class TestGetBuilderName(unittest.TestCase): + + def test_returns_hostname(self): + with mock.patch('socket.gethostname', return_value='build-host-1'): + self.assertEqual(sbom.get_builder_name(), 'build-host-1') + + def test_strips_whitespace(self): + with mock.patch('socket.gethostname', return_value=' build-host-1\n'): + self.assertEqual(sbom.get_builder_name(), 'build-host-1') + + def test_empty_hostname_is_not_available(self): + for value in ('', ' '): + with mock.patch('socket.gethostname', return_value=value): + self.assertEqual(sbom.get_builder_name(), sbom.NOT_AVAILABLE) + + def test_error_is_not_available(self): + with mock.patch('socket.gethostname', side_effect=OSError('boom')): + self.assertEqual(sbom.get_builder_name(), sbom.NOT_AVAILABLE) + + class TestParseVersion(unittest.TestCase): def test_extracts_dotted_version(self): @@ -474,6 +504,11 @@ def test_reports_git_sha_and_embedder_string(self): self.assertEqual(env['git_sha'], sha) self.assertRegex(env['v8_embedder_string'], r'^-node\.\d+$') + def test_reports_builder(self): + with mock.patch('socket.gethostname', return_value='build-host-1'): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + self.assertEqual(env['builder'], 'build-host-1') + def test_all_tools_missing(self): with mock.patch.object(sbom, '_run', return_value=None): env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) From 8ee2e95db6d91ba349e33a1789e0e11b34713363 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 12:02:32 +0000 Subject: [PATCH 08/13] tools: record configure command in SBOM Add the ./configure command from config.status to the SBOM as the nodejs:configure-command property on the formulation entry, or N/A when it can't be determined. Assisted-by: IBM Bob Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 51 ++++++++++++++++++----- tools/sbom/test_generate_sbom.py | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index 4d4920ac5fde..05d6a83028f3 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -131,6 +131,29 @@ def get_builder_name(): return NOT_AVAILABLE +def get_configure_command(root): + """Return the ./configure command that configured the tree, or NOT_AVAILABLE. + + configure.py writes config.status as a shell script ending in + `exec ./configure `. A quoted argument may contain a + newline, so the whole text after `exec ` is tokenized, not just one line. + Only argv is recorded there, not environment variables such as CFLAGS. + """ + text = _read_text(Path(root) / 'config.status') + if text is None: + return NOT_AVAILABLE + match = re.search(r'^exec (.*)\Z', text, re.MULTILINE | re.DOTALL) + if match is None: + return NOT_AVAILABLE + try: + args = shlex.split(match.group(1)) + except ValueError: + return NOT_AVAILABLE + if not args or args[0] != './configure': + return NOT_AVAILABLE + return shlex.join(args) + + def collect_build_environment(root, config_vars): """Return a dict of build-environment facts for build_sbom(). @@ -157,6 +180,7 @@ def collect_build_environment(root, config_vars): 'git_sha': get_git_sha(root), 'v8_embedder_string': get_v8_embedder_string(root), 'builder': get_builder_name(), + 'configure_command': get_configure_command(root), } @@ -706,18 +730,23 @@ def build_sbom(node_version, components, lib_files, build_env=None): ], } + formula = {'bom-ref': 'build-environment'} if compilers: - sbom['formulation'] = [{ - 'bom-ref': 'build-environment', - 'components': [{ - 'bom-ref': f'build-environment:{c["name"]}', - 'type': 'application', - 'name': c['name'], - 'version': (c['output'] if c['output'] == NOT_AVAILABLE - else _parse_version(c['output'])), - 'description': f'Build tool from `{c["command"]}`', - } for c in compilers], - }] + formula['components'] = [{ + 'bom-ref': f'build-environment:{c["name"]}', + 'type': 'application', + 'name': c['name'], + 'version': (c['output'] if c['output'] == NOT_AVAILABLE + else _parse_version(c['output'])), + 'description': f'Build tool from `{c["command"]}`', + } for c in compilers] + configure_command = build_env.get('configure_command') + if configure_command: + # Published as-is: it may contain local paths (see the header comment). + formula['properties'] = [{'name': 'nodejs:configure-command', + 'value': configure_command}] + if len(formula) > 1: + sbom['formulation'] = [formula] return sbom diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py index af7354b32563..a6d5accc6ef5 100644 --- a/tools/sbom/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -329,6 +329,24 @@ def test_no_git_properties_without_build_env(self): def test_no_formulation_without_build_env(self): self.assertNotIn('formulation', sbom.build_sbom('27.0.0', [], [])) + def test_configure_command_is_a_formulation_property(self): + doc = sbom.build_sbom('27.0.0', [], [], + {'configure_command': './configure --debug'}) + formula = doc['formulation'][0] + self.assertNotIn('components', formula) + self.assertEqual(formula['properties'], + [{'name': 'nodejs:configure-command', + 'value': './configure --debug'}]) + + def test_configure_command_alongside_compilers(self): + doc = sbom.build_sbom('27.0.0', [], [], { + 'compilers': [{'name': 'cc', 'command': 'gcc', 'output': 'gcc 13.2.1'}], + 'configure_command': sbom.NOT_AVAILABLE, + }) + formula = doc['formulation'][0] + self.assertEqual(len(formula['components']), 1) + self.assertEqual(formula['properties'][0]['value'], 'N/A') + class TestRun(unittest.TestCase): @@ -456,6 +474,49 @@ def test_missing_file_is_not_available(self): self.assertEqual(sbom.get_v8_embedder_string(tmp), sbom.NOT_AVAILABLE) +class TestGetConfigureCommand(unittest.TestCase): + + def _command_for(self, content): + with tempfile.TemporaryDirectory() as tmp: + if content is not None: + with open(path.join(tmp, 'config.status'), 'w', + encoding='utf-8') as f: + f.write(content) + with contextlib.redirect_stderr(io.StringIO()): + return sbom.get_configure_command(tmp) + + def test_typical_file(self): + content = ("#!/bin/sh\nset -x\nexec ./configure --prefix=/opt/node " + "--without-intl\n") + self.assertEqual(self._command_for(content), + './configure --prefix=/opt/node --without-intl') + + def test_no_arguments(self): + self.assertEqual(self._command_for('#!/bin/sh\nset -x\nexec ./configure \n'), + './configure') + + def test_quoted_arguments_with_spaces(self): + content = ("#!/bin/sh\nset -x\nexec ./configure " + "'--prefix=/opt/my node' --debug\n") + self.assertEqual(self._command_for(content), + "./configure '--prefix=/opt/my node' --debug") + + def test_argument_containing_newline(self): + content = "#!/bin/sh\nset -x\nexec ./configure 'a\nb' --debug\n" + self.assertEqual(self._command_for(content), + "./configure 'a\nb' --debug") + + def test_missing_file_is_not_available(self): + self.assertEqual(self._command_for(None), sbom.NOT_AVAILABLE) + + def test_malformed_content_is_not_available(self): + for content in ('', 'garbage\n', '#!/bin/sh\nset -x\n', + '#!/bin/sh\nexec ./other --debug\n', + "#!/bin/sh\nexec ./configure 'unbalanced\n"): + self.assertEqual(self._command_for(content), sbom.NOT_AVAILABLE, + repr(content)) + + class TestGetBuilderName(unittest.TestCase): def test_returns_hostname(self): @@ -489,6 +550,14 @@ def test_falls_back_to_input(self): class TestCollectBuildEnvironment(unittest.TestCase): + def test_reports_configure_command(self): + with tempfile.TemporaryDirectory() as tmp: + with open(path.join(tmp, 'config.status'), 'w', encoding='utf-8') as f: + f.write('#!/bin/sh\nset -x\nexec ./configure --debug\n') + with contextlib.redirect_stderr(io.StringIO()): + env = sbom.collect_build_environment(tmp, {}) + self.assertEqual(env['configure_command'], './configure --debug') + def test_reports_all_tools(self): with mock.patch.object(sbom, '_run', return_value='tool 1.2.3'): env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) From f9a5d4d6085d71db4c75e298d090764b505cbac2 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 12:18:23 +0000 Subject: [PATCH 09/13] tools: add glibc and compiler package versions to SBOM Records the build host's glibc version (via platform.libc_ver()) and the distro package version of each compiler (dpkg or rpm) in the formulation section of the generated CycloneDX document. Linux only; N/A everywhere else. Each probe is independent and never blocks SBOM generation. Assisted-by: IBM Bob Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 97 +++++++++++++++-- tools/sbom/test_generate_sbom.py | 180 +++++++++++++++++++++++++++++-- 2 files changed, 261 insertions(+), 16 deletions(-) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index 05d6a83028f3..5dbad7f36484 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -15,8 +15,10 @@ import glob import json import os +import platform import re import shlex +import shutil import socket import subprocess import sys @@ -154,6 +156,54 @@ def get_configure_command(root): return shlex.join(args) +def get_glibc_version(): + """Return the build host's glibc version string, or NOT_AVAILABLE. + + Uses platform.libc_ver() (pure Python, no subprocess). Only meaningful on + Linux; returns NOT_AVAILABLE on all other platforms and for musl or other + non-glibc libc implementations. + + Note: this is the host's glibc version. Because Node.js does not build + against a sysroot targeting an older glibc, this version becomes the + binary's minimum glibc requirement at run time. + """ + if sys.platform != 'linux': + return NOT_AVAILABLE + lib, version = platform.libc_ver() + if lib == 'glibc' and version: + return version + return NOT_AVAILABLE + + +def get_compiler_package_version(binary_path): + """Return the distro package version that owns `binary_path`, or NOT_AVAILABLE. + + Tries dpkg (Debian/Ubuntu) then rpm (RHEL/Fedora). `binary_path` must be an + absolute path string as returned by shutil.which(). Only runs on Linux. + """ + if sys.platform != 'linux' or not binary_path: + return NOT_AVAILABLE + + # --- dpkg (Debian / Ubuntu) --- + # dpkg -S resolves the package name from the file path. + dpkg_s = _run(['dpkg', '-S', binary_path]) + if dpkg_s: + # Output is "pkgname: /path/to/binary"; extract the package name. + pkg_name = dpkg_s.split(':')[0].strip() + if pkg_name: + version = _run(['dpkg-query', '-W', '-f=${Version}', pkg_name]) + if version: + return version + + # --- rpm (RHEL / Fedora / SUSE) --- + rpm_out = _run(['rpm', '-qf', '--queryformat', '%{VERSION}-%{RELEASE}', + binary_path]) + if rpm_out: + return rpm_out + + return NOT_AVAILABLE + + def collect_build_environment(root, config_vars): """Return a dict of build-environment facts for build_sbom(). @@ -173,10 +223,19 @@ def collect_build_environment(root, config_vars): ('cxx', 'CXX', default_cxx), ('rustc', 'RUSTC', 'rustc'), ('cargo', 'CARGO', 'cargo')): - compilers.append({'name': name, - **get_compiler_version(env_var, default)}) + info = get_compiler_version(env_var, default) + # Resolve the actual binary path for the package-version probe. Only the + # first token is the executable (shlex.split handles wrappers like ccache). + try: + binary = shlex.split(info['command'])[0] if info['command'] else None + except ValueError: + binary = None + info['binary_path'] = shutil.which(binary) if binary else None + compilers.append({'name': name, **info}) + return { 'compilers': compilers, + 'glibc_version': get_glibc_version(), 'git_sha': get_git_sha(root), 'v8_embedder_string': get_v8_embedder_string(root), 'builder': get_builder_name(), @@ -731,15 +790,33 @@ def build_sbom(node_version, components, lib_files, build_env=None): } formula = {'bom-ref': 'build-environment'} + formula_components = [] if compilers: - formula['components'] = [{ - 'bom-ref': f'build-environment:{c["name"]}', - 'type': 'application', - 'name': c['name'], - 'version': (c['output'] if c['output'] == NOT_AVAILABLE - else _parse_version(c['output'])), - 'description': f'Build tool from `{c["command"]}`', - } for c in compilers] + for c in compilers: + pkg_version = get_compiler_package_version(c.get('binary_path')) + comp = { + 'bom-ref': f'build-environment:{c["name"]}', + 'type': 'application', + 'name': c['name'], + 'version': (c['output'] if c['output'] == NOT_AVAILABLE + else _parse_version(c['output'])), + 'description': f'Build tool from `{c["command"]}`', + } + if pkg_version != NOT_AVAILABLE: + comp['properties'] = [{'name': 'nodejs:package-version', + 'value': pkg_version}] + formula_components.append(comp) + glibc_version = build_env.get('glibc_version') + if glibc_version and glibc_version != NOT_AVAILABLE: + formula_components.append({ + 'bom-ref': 'build-environment:glibc', + 'type': 'library', + 'name': 'glibc', + 'version': glibc_version, + 'description': 'GNU C Library on the build host', + }) + if formula_components: + formula['components'] = formula_components configure_command = build_env.get('configure_command') if configure_command: # Published as-is: it may contain local paths (see the header comment). diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py index a6d5accc6ef5..679580451f60 100644 --- a/tools/sbom/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -585,17 +585,185 @@ def test_all_tools_missing(self): for c in env['compilers'])) def test_platform_defaults(self): - for platform, cc, cxx in (('darwin', 'cc', 'c++'), - ('win32', 'clang', 'clang'), - ('linux', 'gcc', 'g++')): + for plat, cc, cxx in (('darwin', 'cc', 'c++'), + ('win32', 'clang', 'clang'), + ('linux', 'gcc', 'g++')): env = {k: v for k, v in os.environ.items() if k not in ('CC', 'CXX')} with mock.patch.dict(os.environ, env, clear=True), \ - mock.patch.object(sys, 'platform', platform), \ - mock.patch.object(sbom, '_run', return_value='x 1.0'): + mock.patch.object(sys, 'platform', plat), \ + mock.patch.object(sbom, '_run', return_value='x 1.0'), \ + mock.patch('shutil.which', return_value='/usr/bin/cc'): result = sbom.collect_build_environment(sbom.REPO_ROOT, {}) commands = {c['name']: c['command'] for c in result['compilers']} - self.assertEqual((commands['cc'], commands['cxx']), (cc, cxx), platform) + self.assertEqual((commands['cc'], commands['cxx']), (cc, cxx), plat) + + +class TestGetGlibcVersion(unittest.TestCase): + + def test_returns_version_on_linux(self): + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch('platform.libc_ver', return_value=('glibc', '2.34')): + self.assertEqual(sbom.get_glibc_version(), '2.34') + + def test_non_linux_is_not_available(self): + for plat in ('darwin', 'win32', 'freebsd7'): + with mock.patch.object(sys, 'platform', plat): + self.assertEqual(sbom.get_glibc_version(), sbom.NOT_AVAILABLE, plat) + + def test_musl_is_not_available(self): + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch('platform.libc_ver', return_value=('', '')): + self.assertEqual(sbom.get_glibc_version(), sbom.NOT_AVAILABLE) + + def test_empty_version_is_not_available(self): + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch('platform.libc_ver', return_value=('glibc', '')): + self.assertEqual(sbom.get_glibc_version(), sbom.NOT_AVAILABLE) + + +class TestGetCompilerPackageVersion(unittest.TestCase): + + def test_non_linux_is_not_available(self): + for plat in ('darwin', 'win32'): + with mock.patch.object(sys, 'platform', plat): + self.assertEqual( + sbom.get_compiler_package_version('/usr/bin/gcc'), sbom.NOT_AVAILABLE) + + def test_no_binary_path_is_not_available(self): + with mock.patch.object(sys, 'platform', 'linux'): + for bad in (None, ''): + self.assertEqual( + sbom.get_compiler_package_version(bad), sbom.NOT_AVAILABLE) + + def test_dpkg_hit(self): + def fake_run(args, **kw): + if args[:2] == ['dpkg', '-S']: + return 'gcc-13: /usr/bin/gcc' + if args[:2] == ['dpkg-query', '-W']: + return '13.2.1-6ubuntu1' + return None + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch.object(sbom, '_run', side_effect=fake_run): + self.assertEqual( + sbom.get_compiler_package_version('/usr/bin/gcc'), '13.2.1-6ubuntu1') + + def test_rpm_hit(self): + def fake_run(args, **kw): + if args[:2] == ['dpkg', '-S']: + return None + if args[:1] == ['rpm']: + return '13.3.1-1.el9' + return None + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch.object(sbom, '_run', side_effect=fake_run): + self.assertEqual( + sbom.get_compiler_package_version('/usr/bin/gcc'), '13.3.1-1.el9') + + def test_neither_package_manager_found(self): + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch.object(sbom, '_run', return_value=None): + self.assertEqual( + sbom.get_compiler_package_version('/usr/bin/gcc'), sbom.NOT_AVAILABLE) + + def test_dpkg_without_colon_falls_through_to_rpm(self): + # malformed dpkg -S output → falls through to rpm + def fake_run(args, **kw): + if args[:2] == ['dpkg', '-S']: + return 'no colon here' + if args[:1] == ['rpm']: + return '13.3.1-1.el9' + return None + with mock.patch.object(sys, 'platform', 'linux'), \ + mock.patch.object(sbom, '_run', side_effect=fake_run): + self.assertEqual( + sbom.get_compiler_package_version('/usr/bin/gcc'), '13.3.1-1.el9') + + +class TestBuildSbomGlibc(unittest.TestCase): + """Verify that glibc and compiler package-version appear correctly in build_sbom.""" + + def _compiler(self, name='cc', command='gcc', output='gcc 13.2.1', + binary_path='/usr/bin/gcc'): + return {'name': name, 'command': command, 'output': output, + 'binary_path': binary_path} + + def test_glibc_component_present(self): + build_env = { + 'compilers': [], + 'glibc_version': '2.34', + } + doc = sbom.build_sbom('27.0.0', [], [], build_env) + comps = doc['formulation'][0]['components'] + glibc = next(c for c in comps if c['name'] == 'glibc') + self.assertEqual(glibc['version'], '2.34') + self.assertEqual(glibc['bom-ref'], 'build-environment:glibc') + self.assertEqual(glibc['type'], 'library') + + def test_glibc_not_available_not_emitted_as_component(self): + build_env = {'glibc_version': sbom.NOT_AVAILABLE} + doc = sbom.build_sbom('27.0.0', [], [], build_env) + # N/A glibc should not produce a formulation at all (no other fields set). + self.assertNotIn('formulation', doc) + + def test_compiler_package_version_property(self): + def fake_pkg(path): + return '13.2.1-6ubuntu1' if path else sbom.NOT_AVAILABLE + with mock.patch.object(sbom, 'get_compiler_package_version', + side_effect=fake_pkg): + build_env = {'compilers': [self._compiler()]} + doc = sbom.build_sbom('27.0.0', [], [], build_env) + comp = doc['formulation'][0]['components'][0] + self.assertEqual(comp['properties'], + [{'name': 'nodejs:package-version', + 'value': '13.2.1-6ubuntu1'}]) + + def test_compiler_without_package_version_has_no_properties(self): + with mock.patch.object(sbom, 'get_compiler_package_version', + return_value=sbom.NOT_AVAILABLE): + build_env = {'compilers': [self._compiler(binary_path=None)]} + doc = sbom.build_sbom('27.0.0', [], [], build_env) + comp = doc['formulation'][0]['components'][0] + self.assertNotIn('properties', comp) + + def test_glibc_alongside_compilers(self): + with mock.patch.object(sbom, 'get_compiler_package_version', + return_value=sbom.NOT_AVAILABLE): + build_env = { + 'compilers': [self._compiler()], + 'glibc_version': '2.38', + } + doc = sbom.build_sbom('27.0.0', [], [], build_env) + names = [c['name'] for c in doc['formulation'][0]['components']] + self.assertIn('cc', names) + self.assertIn('glibc', names) + # glibc comes last (after compilers) + self.assertEqual(names[-1], 'glibc') + + +class TestCollectBuildEnvironmentGlibc(unittest.TestCase): + + def test_glibc_version_key_present(self): + with mock.patch.object(sbom, '_run', return_value='tool 1.0'), \ + mock.patch.object(sbom, 'get_glibc_version', return_value='2.34'): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + self.assertEqual(env['glibc_version'], '2.34') + + def test_binary_path_resolved_for_compilers(self): + with mock.patch.object(sbom, '_run', return_value='gcc 13.2.1'), \ + mock.patch.object(sbom, 'get_glibc_version', return_value='2.34'), \ + mock.patch('shutil.which', return_value='/usr/bin/gcc'): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + for c in env['compilers']: + self.assertIn('binary_path', c) + + def test_binary_path_is_none_when_which_fails(self): + with mock.patch.object(sbom, '_run', return_value='gcc 13.2.1'), \ + mock.patch.object(sbom, 'get_glibc_version', return_value=sbom.NOT_AVAILABLE), \ + mock.patch('shutil.which', return_value=None): + env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) + for c in env['compilers']: + self.assertIsNone(c['binary_path']) if __name__ == '__main__': From 1a994c5c00fd117c9a0306d6a410e4675861ed33 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 12:43:30 +0000 Subject: [PATCH 10/13] tools: omit empty leaf entries from SBOM dependencies Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index 5dbad7f36484..54cd63f2b049 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -780,12 +780,12 @@ def build_sbom(node_version, components, lib_files, build_env=None): 'components': components, 'dependencies': [ # All components in the list are linked into the binary; excluded - # deps are omitted before reaching here. None declare sub-dependencies. + # deps are omitted before reaching here. Leaf entries (no + # sub-dependencies) are intentionally omitted. { 'ref': root_ref, 'dependsOn': [c['bom-ref'] for c in components], }, - *({'ref': c['bom-ref'], 'dependsOn': []} for c in components), ], } From 69a3c3dcc331422efcfae2ffacb5a4b44920d381 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 12:46:24 +0000 Subject: [PATCH 11/13] tools: add SBOM generation documentation Signed-off-by: Beth Griggs --- tools/sbom/README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tools/sbom/README.md diff --git a/tools/sbom/README.md b/tools/sbom/README.md new file mode 100644 index 000000000000..81f48f66135c --- /dev/null +++ b/tools/sbom/README.md @@ -0,0 +1,43 @@ +# SBOM generation + +Generates a [CycloneDX](https://cyclonedx.org/) Software Bill of Materials +(SBOM) in JSON describing the bundled runtime dependencies of the Node.js +binary. + +## Usage + +Run `./configure` first. The set of dependencies that end up in the binary is +read from `config.gypi`. + +```bash +make sbom # write out/Release/node-v-sbom.json +make test-sbom # validate the SBOM written by `make sbom` +``` + +The script can also be run directly: + +```bash +python tools/sbom/generate_sbom.py [--output PATH] +python tools/sbom/generate_sbom.py --validate [--output PATH] +``` + +## What the SBOM contains + +* `metadata.component`: the Node.js binary itself. +* `components`: each bundled dependency that is linked into the binary, with + name, version and purl. Dependencies excluded by the configure options are + omitted. +* `dependencies`: a single entry from the Node.js binary to every component. + Sub-dependencies of bundled dependencies are not recorded, and components + with no dependencies of their own get no entry. +* `formulation`: build environment details (compilers, glibc version and the + configure command), when they can be determined. The configure command is + published as-is and may contain local paths. + +A dependency whose version cannot be read is recorded as `unknown` with a +warning rather than failing the build. + +## Adding a dependency + +Add an entry to `DEP_CATALOG` in `generate_sbom.py` with its name, purl base, +version extractor and, if it is conditional, its scope. From 919c105aa72b0b65882c4fd5e5cc9b12422dce80 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 13:26:32 +0000 Subject: [PATCH 12/13] tools: use hashes array instead of comment for git SHA in SBOM Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 38 +++++++++++++++++++------------- tools/sbom/test_generate_sbom.py | 36 ++++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 24 deletions(-) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index 54cd63f2b049..27179e571fef 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -103,15 +103,20 @@ def _parse_version(output): def get_git_sha(root): - """Return the commit `root` is checked out at, or NOT_AVAILABLE. + """Return (alg, digest) for the commit `root` is checked out at. - Source tarballs are not git checkouts, and git may not be installed. + Returns (NOT_AVAILABLE, NOT_AVAILABLE) when the repo is a source tarball + or git is not installed. SHA-1 repositories produce a 40-hex-digit digest + (alg 'SHA-1'); SHA-256 repositories produce a 64-hex-digit digest + (alg 'SHA-256'). """ output = _run(['git', '-C', str(root), 'rev-parse', 'HEAD']) - # SHA-1 repositories give 40 hex digits, SHA-256 repositories give 64. - if output and re.fullmatch(r'[0-9a-f]{40}|[0-9a-f]{64}', output): - return output - return NOT_AVAILABLE + if output: + if re.fullmatch(r'[0-9a-f]{40}', output): + return 'SHA-1', output + if re.fullmatch(r'[0-9a-f]{64}', output): + return 'SHA-256', output + return NOT_AVAILABLE, NOT_AVAILABLE def get_v8_embedder_string(root): @@ -233,10 +238,12 @@ def collect_build_environment(root, config_vars): info['binary_path'] = shutil.which(binary) if binary else None compilers.append({'name': name, **info}) + git_alg, git_sha = get_git_sha(root) return { 'compilers': compilers, 'glibc_version': get_glibc_version(), - 'git_sha': get_git_sha(root), + 'git_alg': git_alg, + 'git_sha': git_sha, 'v8_embedder_string': get_v8_embedder_string(root), 'builder': get_builder_name(), 'configure_command': get_configure_command(root), @@ -739,15 +746,16 @@ def build_sbom(node_version, components, lib_files, build_env=None): } for c in compilers) git_sha = build_env.get('git_sha') - if git_sha: + git_alg = build_env.get('git_alg') + if git_sha and git_sha != NOT_AVAILABLE: properties.append({'name': 'nodejs:git-sha', 'value': git_sha}) - if git_sha != NOT_AVAILABLE: - # Pins the exact source tree, which the purl version alone does not. - root_component['externalReferences'] = [{ - 'type': 'vcs', - 'url': 'https://github.com/nodejs/node', - 'comment': f'commit {git_sha}', - }] + # Pins the exact source tree, which the purl version alone does not. + # Uses the hashes array so the algorithm is explicit and machine-readable. + root_component['externalReferences'] = [{ + 'type': 'vcs', + 'url': 'https://github.com/nodejs/node', + 'hashes': [{'alg': git_alg, 'content': git_sha}], + }] v8_embedder_string = build_env.get('v8_embedder_string') if v8_embedder_string: properties.append({'name': 'nodejs:v8-embedder-string', diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py index 679580451f60..b7fe5ad40f20 100644 --- a/tools/sbom/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -287,10 +287,11 @@ def test_compilers_are_emitted(self): self.assertEqual(components[0]['type'], 'application') self.assertIn('ccache gcc', components[0]['description']) - def test_git_sha_and_embedder_string_are_emitted(self): + def test_git_sha1_and_embedder_string_are_emitted(self): sha = 'a' * 40 doc = sbom.build_sbom('27.0.0', [], [], - {'git_sha': sha, 'v8_embedder_string': '-node.15'}) + {'git_alg': 'SHA-1', 'git_sha': sha, + 'v8_embedder_string': '-node.15'}) props = {p['name']: p['value'] for p in doc['metadata']['properties']} self.assertEqual(props['nodejs:git-sha'], sha) self.assertEqual(props['nodejs:v8-embedder-string'], '-node.15') @@ -298,15 +299,28 @@ def test_git_sha_and_embedder_string_are_emitted(self): self.assertEqual(refs, [{ 'type': 'vcs', 'url': 'https://github.com/nodejs/node', - 'comment': 'commit ' + sha, + 'hashes': [{'alg': 'SHA-1', 'content': sha}], + }]) + + def test_git_sha256_is_emitted_with_correct_alg(self): + sha = 'b' * 64 + doc = sbom.build_sbom('27.0.0', [], [], + {'git_alg': 'SHA-256', 'git_sha': sha, + 'v8_embedder_string': '-node.15'}) + refs = doc['metadata']['component']['externalReferences'] + self.assertEqual(refs, [{ + 'type': 'vcs', + 'url': 'https://github.com/nodejs/node', + 'hashes': [{'alg': 'SHA-256', 'content': sha}], }]) def test_unknown_git_sha_omits_vcs_reference(self): doc = sbom.build_sbom('27.0.0', [], [], {'git_sha': sbom.NOT_AVAILABLE, + 'git_alg': sbom.NOT_AVAILABLE, 'v8_embedder_string': sbom.NOT_AVAILABLE}) props = {p['name']: p['value'] for p in doc['metadata']['properties']} - self.assertEqual(props['nodejs:git-sha'], 'N/A') + self.assertNotIn('nodejs:git-sha', props) self.assertEqual(props['nodejs:v8-embedder-string'], 'N/A') self.assertNotIn('externalReferences', doc['metadata']['component']) @@ -419,29 +433,32 @@ class TestGetGitSha(unittest.TestCase): def test_valid_sha1(self): sha = '0123456789abcdef' * 2 + '01234567' with mock.patch.object(sbom, '_run', return_value=sha) as run: - self.assertEqual(sbom.get_git_sha('/some/root'), sha) + self.assertEqual(sbom.get_git_sha('/some/root'), ('SHA-1', sha)) run.assert_called_once_with(['git', '-C', '/some/root', 'rev-parse', 'HEAD']) def test_valid_sha256(self): sha = 'ab' * 32 with mock.patch.object(sbom, '_run', return_value=sha): - self.assertEqual(sbom.get_git_sha('.'), sha) + self.assertEqual(sbom.get_git_sha('.'), ('SHA-256', sha)) def test_garbage_is_not_available(self): for output in ('HEAD', 'fatal: not a git repository', 'ABC' * 14, 'g' * 40, 'a' * 39, 'a' * 41, '0' * 40 + '\n' + '0' * 40): with mock.patch.object(sbom, '_run', return_value=output): - self.assertEqual(sbom.get_git_sha('.'), sbom.NOT_AVAILABLE, output) + self.assertEqual(sbom.get_git_sha('.'), + (sbom.NOT_AVAILABLE, sbom.NOT_AVAILABLE), output) def test_git_failure_is_not_available(self): with mock.patch.object(sbom, '_run', return_value=None): - self.assertEqual(sbom.get_git_sha('.'), sbom.NOT_AVAILABLE) + self.assertEqual(sbom.get_git_sha('.'), + (sbom.NOT_AVAILABLE, sbom.NOT_AVAILABLE)) def test_outside_a_repository(self): with tempfile.TemporaryDirectory() as tmp: # Stop git searching parent directories for a repository. with mock.patch.dict(os.environ, {'GIT_CEILING_DIRECTORIES': tmp}): - self.assertEqual(sbom.get_git_sha(tmp), sbom.NOT_AVAILABLE) + self.assertEqual(sbom.get_git_sha(tmp), + (sbom.NOT_AVAILABLE, sbom.NOT_AVAILABLE)) class TestGetV8EmbedderString(unittest.TestCase): @@ -571,6 +588,7 @@ def test_reports_git_sha_and_embedder_string(self): with mock.patch.object(sbom, '_run', return_value=sha): env = sbom.collect_build_environment(sbom.REPO_ROOT, {}) self.assertEqual(env['git_sha'], sha) + self.assertEqual(env['git_alg'], 'SHA-1') self.assertRegex(env['v8_embedder_string'], r'^-node\.\d+$') def test_reports_builder(self): From 44e462b4e571491d7e167db3e7ea15806c255f50 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Wed, 28 Oct 2026 14:25:29 +0000 Subject: [PATCH 13/13] tools: add LLVM version property for Apple Clang in SBOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apple Clang's --version reports the Xcode toolchain version, not the upstream LLVM version. CVE scanners need the LLVM version. Infer it from the build number in the version string (clang-2100 → LLVM 21) and record it as nodejs:llvm-version in the formulation section. Assisted-by: IBM Bob Signed-off-by: Beth Griggs --- tools/sbom/generate_sbom.py | 28 +++++++++++++++-- tools/sbom/test_generate_sbom.py | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py index 27179e571fef..469a90233ebb 100644 --- a/tools/sbom/generate_sbom.py +++ b/tools/sbom/generate_sbom.py @@ -102,6 +102,24 @@ def _parse_version(output): return match.group(0) if match else output +def get_apple_clang_llvm_version(version_output): + """Return the inferred upstream LLVM major version for Apple Clang, or None. + + Apple encodes the LLVM major version in the build number inside parentheses: + Apple clang version 21.0.0 (clang-2100.3.34.2) + ^^^^ + LLVM major = first 4+-digit cluster after 'clang-' // 100. + Returns None if the output is not from Apple Clang or the build number + cannot be parsed. + """ + if not version_output or 'Apple clang' not in version_output: + return None + m = re.search(r'clang-(\d{4,})', version_output) + if not m: + return None + return str(int(m.group(1)) // 100) + + def get_git_sha(root): """Return (alg, digest) for the commit `root` is checked out at. @@ -810,9 +828,15 @@ def build_sbom(node_version, components, lib_files, build_env=None): else _parse_version(c['output'])), 'description': f'Build tool from `{c["command"]}`', } + comp_props = [] if pkg_version != NOT_AVAILABLE: - comp['properties'] = [{'name': 'nodejs:package-version', - 'value': pkg_version}] + comp_props.append({'name': 'nodejs:package-version', + 'value': pkg_version}) + llvm_ver = get_apple_clang_llvm_version(c.get('output', '')) + if llvm_ver: + comp_props.append({'name': 'nodejs:llvm-version', 'value': llvm_ver}) + if comp_props: + comp['properties'] = comp_props formula_components.append(comp) glibc_version = build_env.get('glibc_version') if glibc_version and glibc_version != NOT_AVAILABLE: diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py index b7fe5ad40f20..c9ff563e44d2 100644 --- a/tools/sbom/test_generate_sbom.py +++ b/tools/sbom/test_generate_sbom.py @@ -287,6 +287,27 @@ def test_compilers_are_emitted(self): self.assertEqual(components[0]['type'], 'application') self.assertIn('ccache gcc', components[0]['description']) + def test_apple_clang_emits_llvm_version_property(self): + build_env = {'compilers': [ + {'name': 'cc', 'command': 'cc', + 'output': 'Apple clang version 21.0.0 (clang-2100.3.34.2)'}, + ]} + doc = sbom.build_sbom('27.0.0', [], [], build_env) + cc_comp = doc['formulation'][0]['components'][0] + self.assertEqual(cc_comp['version'], '21.0.0') + props = {p['name']: p['value'] for p in cc_comp.get('properties', [])} + self.assertEqual(props['nodejs:llvm-version'], '21') + + def test_non_apple_clang_omits_llvm_version_property(self): + build_env = {'compilers': [ + {'name': 'cc', 'command': 'gcc', + 'output': 'gcc (GCC) 13.2.1 20240101'}, + ]} + doc = sbom.build_sbom('27.0.0', [], [], build_env) + cc_comp = doc['formulation'][0]['components'][0] + props = {p['name']: p['value'] for p in cc_comp.get('properties', [])} + self.assertNotIn('nodejs:llvm-version', props) + def test_git_sha1_and_embedder_string_are_emitted(self): sha = 'a' * 40 doc = sbom.build_sbom('27.0.0', [], [], @@ -565,6 +586,37 @@ def test_falls_back_to_input(self): self.assertEqual(sbom._parse_version('mystery tool'), 'mystery tool') +class TestGetAppleClangLlvmVersion(unittest.TestCase): + + def test_extracts_llvm_major_from_build_number(self): + # clang-2100 -> LLVM 21 + self.assertEqual( + sbom.get_apple_clang_llvm_version( + 'Apple clang version 21.0.0 (clang-2100.3.34.2)'), + '21') + + def test_extracts_llvm_major_xcode_16(self): + # clang-1600 -> LLVM 16 + self.assertEqual( + sbom.get_apple_clang_llvm_version( + 'Apple clang version 16.0.0 (clang-1600.0.26.4)'), + '16') + + def test_returns_none_for_non_apple_clang(self): + self.assertIsNone( + sbom.get_apple_clang_llvm_version('gcc (GCC) 13.2.1 20240101')) + self.assertIsNone( + sbom.get_apple_clang_llvm_version('clang version 18.0.0')) + + def test_returns_none_for_empty_or_none(self): + self.assertIsNone(sbom.get_apple_clang_llvm_version('')) + self.assertIsNone(sbom.get_apple_clang_llvm_version(None)) + + def test_returns_none_when_build_number_missing(self): + self.assertIsNone( + sbom.get_apple_clang_llvm_version('Apple clang version 21.0.0')) + + class TestCollectBuildEnvironment(unittest.TestCase): def test_reports_configure_command(self):