From cb0fc5f7921a78a4d3af25b8ad8e3d401e29e126 Mon Sep 17 00:00:00 2001 From: Beth Griggs Date: Thu, 15 Oct 2026 13:38:29 +0100 Subject: [PATCH] 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()