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()