diff --git a/Makefile b/Makefile index 31d7d911c987..6c44385a48d8 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/sbom/generate_sbom.py + +.PHONY: test-sbom +test-sbom: ## Validate the SBOM written by `make sbom`. + $(PYTHON) tools/sbom/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..a8ec6af248c7 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/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 + 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/sbom/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/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. diff --git a/tools/sbom/generate_sbom.py b/tools/sbom/generate_sbom.py new file mode 100644 index 000000000000..469a90233ebb --- /dev/null +++ b/tools/sbom/generate_sbom.py @@ -0,0 +1,950 @@ +#!/usr/bin/env python3 +# Generates a CycloneDX 1.7 Software Bill of Materials (SBOM) describing the +# bundled runtime dependencies of the Node.js binary. +# +# Usage: +# 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 +# end up in the binary is read from config.gypi. + +import argparse +import datetime +import glob +import json +import os +import platform +import re +import shlex +import shutil +import socket +import subprocess +import sys +import uuid +from pathlib import Path + +REPO_ROOT = Path(__file__).parent.parent.parent + +# 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.""" + 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) + + +# --------------------------------------------------------------------------- +# 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_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. + + 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']) + 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): + """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 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 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 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(). + + 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')): + 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}) + + git_alg, git_sha = get_git_sha(root) + return { + 'compilers': compilers, + 'glibc_version': get_glibc_version(), + '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), + } + + +# --------------------------------------------------------------------------- +# 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', + # 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'), + }, + { + '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. + # 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'), + }, + # --- 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'), + }, + { + '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'), + }, + { + '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', + # 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'), + }, + { + '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', + # 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'), + }, + # --- 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, 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, + 'type': 'framework', + 'name': 'nodejs', + 'version': node_version, + 'description': 'Node.js JavaScript runtime', + 'purl': f'pkg:generic/nodejs/node@{node_version}', + } + + 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') + git_alg = build_env.get('git_alg') + if git_sha and git_sha != NOT_AVAILABLE: + properties.append({'name': 'nodejs:git-sha', 'value': 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', + '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', + 'bomFormat': 'CycloneDX', + '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': { + 'components': [{ + 'type': 'application', + 'authors': [{'name': 'Node.js'}], + 'name': 'generate_sbom.py', + 'version': node_version, + }], + }, + 'component': root_component, + 'properties': properties, + }, + 'components': components, + 'dependencies': [ + # All components in the list are linked into the binary; excluded + # 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], + }, + ], + } + + formula = {'bom-ref': 'build-environment'} + formula_components = [] + if 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"]}`', + } + comp_props = [] + if pkg_version != NOT_AVAILABLE: + 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: + 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). + formula['properties'] = [{'name': 'nodejs:configure-command', + 'value': configure_command}] + if len(formula) > 1: + sbom['formulation'] = [formula] + return sbom + + +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.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"') + + 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) + 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. + 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', []), + collect_build_environment(REPO_ROOT, config_vars)) + + +def main(): + parser = argparse.ArgumentParser( + 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)') + 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() diff --git a/tools/sbom/test_generate_sbom.py b/tools/sbom/test_generate_sbom.py new file mode 100644 index 000000000000..c9ff563e44d2 --- /dev/null +++ b/tools/sbom/test_generate_sbom.py @@ -0,0 +1,840 @@ +import contextlib +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__), '..', '..')) +sys.path.insert(0, path.join(ROOT, 'tools', 'sbom')) +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.7') + 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_root_depends_on_all_given_components(self): + components = [ + self._component('zlib', '1.3.0'), + 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_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', [], [], + {'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') + refs = doc['metadata']['component']['externalReferences'] + self.assertEqual(refs, [{ + 'type': 'vcs', + 'url': 'https://github.com/nodejs/node', + '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.assertNotIn('nodejs:git-sha', props) + 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']] + 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', [], [])) + + 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): + + 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-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-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, 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, 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, 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 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): + 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): + 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 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): + 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, {}) + 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.assertEqual(env['git_alg'], 'SHA-1') + 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, {}) + self.assertTrue(all(c['output'] == sbom.NOT_AVAILABLE + for c in env['compilers'])) + + def test_platform_defaults(self): + 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', 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), 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__': + unittest.main()