aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorLinus Torvalds <torvalds@linux-foundation.org>2026-06-22 12:06:22 -0700
committerLinus Torvalds <torvalds@linux-foundation.org>2026-06-22 12:06:22 -0700
commite4b4bfaa5090760925b98848aa3e0fc10b3c574f (patch)
tree1a67add78f7c9734602fa2816644a3db22ae86a0 /scripts
parent8a500fd09385a13ba598cda651f2e4ac40bfa578 (diff)
parent880bae5f1269b4d81bb2a254963e84377cd37bc1 (diff)
Merge tag 'spdx-7.2-rc1' of git://git.kernel.org/pub/scm/linux/kernel/git/gregkh/spdx
Pull SPDX updates from Greg KH: "Here is a "big" set of SPDX-like patches for 7.2-rc1. It is the addition of the ability for the kernel build process to generate a Software Bill of Materials (SBOM) in the SPDX format, that matches up exactly with just the files that are actually built for the specific kernel image generated. To generate a sbom, after the kernel has been built, just do: make sbom and marvel at the JSON file that is generated... This is needed by users for environments in which a SBOM is required (medical, automotive, anything shipped in the EU, etc.) and cuts down by a massive size the "naive" SBOM solution that many vendors have done by just including _all_ of the kernel files in the resulting document. This result is still a giant JSON file, that I am told parses properly, so we just have to trust that it is properly inclusive as attempting to parse that thing by hand is impossible. The scripts here are self-contained python scripts, no additional libraries or tools to create the SBOM are needed, which is important for many build systems. Overall it's just a bit over 4000 lines of "simple" python code, the most complex part is the regex matching lines, but those are nothing compared to what we maintain in scripts/checkpatch.pl today... The various parts where the tool touches the kbuild subsystem have been acked by the kbuild maintainer, so all should be good here. All of these patches have been in linux-next for weeks with no reported problems" * tag 'spdx-7.2-rc1' of git://git.kernel.org/pub/scm/linux/kernel/git/gregkh/spdx: scripts/sbom: add unit tests for SPDX-License-Identifier parsing scripts/sbom: add unit tests for command parsers scripts/sbom: add SPDX build graph scripts/sbom: add SPDX source graph scripts/sbom: add SPDX output graph scripts/sbom: collect file metadata scripts/sbom: add shared SPDX elements scripts/sbom: add JSON-LD serialization scripts/sbom: add SPDX classes scripts/sbom: add additional dependency sources for cmd graph scripts/sbom: add cmd graph generation scripts/sbom: add command parsers scripts/sbom: setup sbom logging scripts/sbom: integrate script in make process scripts/sbom: add documentation
Diffstat (limited to 'scripts')
-rw-r--r--scripts/sbom/sbom.py135
-rw-r--r--scripts/sbom/sbom/__init__.py0
-rw-r--r--scripts/sbom/sbom/cmd_graph/__init__.py7
-rw-r--r--scripts/sbom/sbom/cmd_graph/cmd_file.py162
-rw-r--r--scripts/sbom/sbom/cmd_graph/cmd_graph.py46
-rw-r--r--scripts/sbom/sbom/cmd_graph/cmd_graph_node.py142
-rw-r--r--scripts/sbom/sbom/cmd_graph/deps_parser.py52
-rw-r--r--scripts/sbom/sbom/cmd_graph/hardcoded_dependencies.py87
-rw-r--r--scripts/sbom/sbom/cmd_graph/incbin_parser.py42
-rw-r--r--scripts/sbom/sbom/cmd_graph/savedcmd_parser/__init__.py6
-rw-r--r--scripts/sbom/sbom/cmd_graph/savedcmd_parser/command_parser_registry.py516
-rw-r--r--scripts/sbom/sbom/cmd_graph/savedcmd_parser/command_splitter.py128
-rw-r--r--scripts/sbom/sbom/cmd_graph/savedcmd_parser/savedcmd_parser.py67
-rw-r--r--scripts/sbom/sbom/cmd_graph/savedcmd_parser/tokenizer.py92
-rw-r--r--scripts/sbom/sbom/config.py320
-rw-r--r--scripts/sbom/sbom/environment.py192
-rw-r--r--scripts/sbom/sbom/path_utils.py22
-rw-r--r--scripts/sbom/sbom/sbom_logging.py94
-rw-r--r--scripts/sbom/sbom/spdx/__init__.py7
-rw-r--r--scripts/sbom/sbom/spdx/build.py17
-rw-r--r--scripts/sbom/sbom/spdx/core.py170
-rw-r--r--scripts/sbom/sbom/spdx/serialization.py62
-rw-r--r--scripts/sbom/sbom/spdx/simplelicensing.py20
-rw-r--r--scripts/sbom/sbom/spdx/software.py69
-rw-r--r--scripts/sbom/sbom/spdx/spdxId.py36
-rw-r--r--scripts/sbom/sbom/spdx_graph/__init__.py7
-rw-r--r--scripts/sbom/sbom/spdx_graph/build_spdx_graphs.py83
-rw-r--r--scripts/sbom/sbom/spdx_graph/kernel_file.py315
-rw-r--r--scripts/sbom/sbom/spdx_graph/shared_spdx_elements.py32
-rw-r--r--scripts/sbom/sbom/spdx_graph/spdx_build_graph.py318
-rw-r--r--scripts/sbom/sbom/spdx_graph/spdx_graph_model.py36
-rw-r--r--scripts/sbom/sbom/spdx_graph/spdx_output_graph.py187
-rw-r--r--scripts/sbom/sbom/spdx_graph/spdx_source_graph.py130
-rw-r--r--scripts/sbom/tests/__init__.py0
-rw-r--r--scripts/sbom/tests/cmd_graph/__init__.py0
-rw-r--r--scripts/sbom/tests/cmd_graph/test_savedcmd_parser.py443
-rw-r--r--scripts/sbom/tests/spdx_graph/__init__.py0
-rw-r--r--scripts/sbom/tests/spdx_graph/test_kernel_file.py35
38 files changed, 4077 insertions, 0 deletions
diff --git a/scripts/sbom/sbom.py b/scripts/sbom/sbom.py
new file mode 100644
index 000000000000..764175b9c893
--- /dev/null
+++ b/scripts/sbom/sbom.py
@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+"""
+Compute software bill of materials in SPDX format describing a kernel build.
+"""
+
+import json
+import logging
+import os
+import sys
+import time
+import uuid
+import sbom.sbom_logging as sbom_logging
+from sbom.config import get_config
+from sbom.path_utils import is_relative_to
+from sbom.spdx import JsonLdSpdxDocument, SpdxIdGenerator
+from sbom.spdx.core import CreationInfo, SpdxDocument
+from sbom.spdx_graph import SpdxIdGeneratorCollection, build_spdx_graphs
+from sbom.cmd_graph import CmdGraph
+
+
+def _exit_with_summary(write_output_on_error: bool = False) -> None:
+ warning_summary = sbom_logging.summarize_warnings()
+ error_summary = sbom_logging.summarize_errors()
+ if warning_summary:
+ logging.warning(warning_summary)
+ if error_summary:
+ logging.error(error_summary)
+ if not write_output_on_error:
+ logging.info(
+ "Use --write-output-on-error to generate output documents even when errors occur. "
+ "Note that in this case the generated documents may be incomplete."
+ )
+ sys.exit(1)
+
+
+def main():
+ # Read config
+ config = get_config()
+
+ # Configure logging
+ logging.basicConfig(
+ level=logging.DEBUG if config.debug else logging.INFO,
+ format="[%(levelname)s] %(message)s",
+ )
+
+ # Build cmd graph
+ logging.debug("Start building cmd graph")
+ start_time = time.time()
+ cmd_graph = CmdGraph.create(config.root_paths, config)
+ logging.debug(f"Built cmd graph in {time.time() - start_time} seconds")
+
+ # Save used files document
+ if config.generate_used_files:
+ if config.src_tree == config.obj_tree:
+ logging.info(
+ f"Extracting all files from the cmd graph to {config.used_files_file_name} "
+ "instead of only source files because source files cannot be "
+ "reliably classified when the source and object trees are identical.",
+ )
+ used_files = [os.path.relpath(node.absolute_path, config.src_tree) for node in cmd_graph]
+ logging.debug(f"Found {len(used_files)} files in cmd graph.")
+ else:
+ used_files = [
+ os.path.relpath(node.absolute_path, config.src_tree)
+ for node in cmd_graph
+ if is_relative_to(node.absolute_path, config.src_tree)
+ and not is_relative_to(node.absolute_path, config.obj_tree)
+ ]
+ logging.debug(f"Found {len(used_files)} source files in cmd graph")
+ if not sbom_logging.has_errors() or config.write_output_on_error:
+ used_files_path = os.path.join(config.output_directory, config.used_files_file_name)
+ with open(used_files_path, "w", encoding="utf-8") as f:
+ f.write("\n".join(str(file_path) for file_path in used_files))
+ logging.debug(f"Successfully saved {used_files_path}")
+
+ if config.generate_spdx is False:
+ _exit_with_summary(config.write_output_on_error)
+ return
+
+ # Build SPDX Documents
+ logging.debug("Start generating SPDX graph based on cmd graph")
+ start_time = time.time()
+
+ # The real uuid will be generated based on the content of the SPDX graphs
+ # to ensure that the same SPDX document is always assigned the same uuid.
+ PLACEHOLDER_UUID = "00000000-0000-0000-0000-000000000000"
+ spdx_id_base_namespace = f"{config.spdxId_prefix}{PLACEHOLDER_UUID}/"
+ spdx_id_generators = SpdxIdGeneratorCollection(
+ base=SpdxIdGenerator(prefix="p", namespace=spdx_id_base_namespace),
+ source=SpdxIdGenerator(prefix="s", namespace=f"{spdx_id_base_namespace}source/"),
+ build=SpdxIdGenerator(prefix="b", namespace=f"{spdx_id_base_namespace}build/"),
+ output=SpdxIdGenerator(prefix="o", namespace=f"{spdx_id_base_namespace}output/"),
+ )
+
+ spdx_graphs = build_spdx_graphs(
+ cmd_graph,
+ spdx_id_generators,
+ config,
+ )
+ spdx_id_uuid = uuid.uuid5(
+ uuid.NAMESPACE_URL,
+ "".join(
+ json.dumps(element.to_dict()) for spdx_graph in spdx_graphs.values() for element in spdx_graph.to_list()
+ ),
+ )
+ logging.debug(f"Generated SPDX graph in {time.time() - start_time} seconds")
+
+ if not sbom_logging.has_errors() or config.write_output_on_error:
+ for kernel_sbom_kind, spdx_graph in spdx_graphs.items():
+ spdx_graph_objects = spdx_graph.to_list()
+ # Add warning and error summary to creation info comment
+ creation_info = next(element for element in spdx_graph_objects if isinstance(element, CreationInfo))
+ creation_info.comment = "\n".join([
+ sbom_logging.summarize_warnings(),
+ sbom_logging.summarize_errors(),
+ ]).strip()
+ # Replace Placeholder uuid with real uuid for spdxIds
+ spdx_document = next(element for element in spdx_graph_objects if isinstance(element, SpdxDocument))
+ for namespaceMap in spdx_document.namespaceMap:
+ namespaceMap.namespace = namespaceMap.namespace.replace(PLACEHOLDER_UUID, str(spdx_id_uuid))
+ # Serialize SPDX graph to JSON-LD
+ spdx_doc = JsonLdSpdxDocument(graph=spdx_graph_objects)
+ save_path = os.path.join(config.output_directory, config.spdx_file_names[kernel_sbom_kind])
+ spdx_doc.save(save_path, config.prettify_json)
+ logging.debug(f"Successfully saved {save_path}")
+
+ _exit_with_summary(config.write_output_on_error)
+
+
+# Call main method
+if __name__ == "__main__":
+ main()
diff --git a/scripts/sbom/sbom/__init__.py b/scripts/sbom/sbom/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
--- /dev/null
+++ b/scripts/sbom/sbom/__init__.py
diff --git a/scripts/sbom/sbom/cmd_graph/__init__.py b/scripts/sbom/sbom/cmd_graph/__init__.py
new file mode 100644
index 000000000000..9d661a5c3d93
--- /dev/null
+++ b/scripts/sbom/sbom/cmd_graph/__init__.py
@@ -0,0 +1,7 @@
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+from .cmd_graph import CmdGraph
+from .cmd_graph_node import CmdGraphNode, CmdGraphNodeConfig
+
+__all__ = ["CmdGraph", "CmdGraphNode", "CmdGraphNodeConfig"]
diff --git a/scripts/sbom/sbom/cmd_graph/cmd_file.py b/scripts/sbom/sbom/cmd_graph/cmd_file.py
new file mode 100644
index 000000000000..dcd63e284a38
--- /dev/null
+++ b/scripts/sbom/sbom/cmd_graph/cmd_file.py
@@ -0,0 +1,162 @@
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+import os
+import re
+from dataclasses import dataclass, field
+from sbom.cmd_graph.deps_parser import parse_cmd_file_deps
+from sbom.cmd_graph.savedcmd_parser import parse_inputs_from_commands
+import sbom.sbom_logging as sbom_logging
+from sbom.path_utils import PathStr
+
+SAVEDCMD_PATTERN = re.compile(r"^(saved)?cmd_.*?:=\s*(?P<full_command>.+)$")
+SOURCE_PATTERN = re.compile(r"^source.*?:=\s*(?P<source_file>.+)$")
+
+
+@dataclass
+class CmdFile:
+ cmd_file_path: PathStr
+ savedcmd: str
+ source: PathStr | None = None
+ deps: list[str] = field(default_factory=list)
+ make_rules: list[str] = field(default_factory=list)
+
+ @classmethod
+ def create(cls, cmd_file_path: PathStr) -> "CmdFile | None":
+ """
+ Parses a .cmd file.
+ .cmd files are assumed to have one of the following structures:
+ 1. Full Cmd File
+ (saved)?cmd_<output> := <command>
+ source_<output> := <main_input>
+ deps_<output> := \
+ <dependencies>
+ <output> := $(deps_<output>)
+ $(deps_<output>):
+
+ 2. Command Only Cmd File
+ (saved)?cmd_<output> := <command>
+
+ 3. Single Dependency Cmd File
+ (saved)?cmd_<output> := <command>
+ <output> : <dependency>
+
+ Args:
+ cmd_file_path (Path): absolute Path to a .cmd file
+
+ Returns:
+ cmd_file (CmdFile): Parsed cmd file.
+ """
+ with open(cmd_file_path, "rt", encoding="utf-8") as f:
+ lines = [line.strip() for line in f.readlines() if line.strip() != "" and not line.startswith("#")]
+
+ # savedcmd
+ match = SAVEDCMD_PATTERN.match(lines[0] if lines else "")
+ if match is None:
+ sbom_logging.error(
+ "Skip parsing '{cmd_file_path}' because no 'savedcmd_' command was found.", cmd_file_path=cmd_file_path
+ )
+ return None
+ savedcmd = match.group("full_command")
+
+ # Command Only Cmd File
+ if len(lines) == 1:
+ return CmdFile(cmd_file_path, savedcmd)
+
+ # Single Dependency Cmd File
+ if len(lines) == 2:
+ parts = lines[1].split(":", 1)
+ if len(parts) != 2:
+ sbom_logging.error(
+ "Skip parsing '{cmd_file_path}'. Expected dependency line '<output>: <dependency>' but got {second_line}", cmd_file_path=cmd_file_path, second_line=lines[1]
+ )
+ return None
+ dep = parts[1].strip()
+ return CmdFile(cmd_file_path, savedcmd, deps=[dep])
+
+ # Full Cmd File
+ # source
+ line1 = SOURCE_PATTERN.match(lines[1])
+ if line1 is None:
+ sbom_logging.error(
+ "Skip parsing '{cmd_file_path}' because no 'source_' entry was found.", cmd_file_path=cmd_file_path
+ )
+ return CmdFile(cmd_file_path, savedcmd)
+ source = line1.group("source_file")
+
+ # deps
+ deps: list[str] = []
+ i = 3 # lines[2] includes the variable assignment but no actual dependency, so we need to start at lines[3].
+ while i < len(lines):
+ if not lines[i].endswith("\\"):
+ break
+ deps.append(lines[i][:-1].strip())
+ i += 1
+
+ # make_rules
+ make_rules = lines[i:]
+
+ return CmdFile(cmd_file_path, savedcmd, source, deps, make_rules)
+
+ def get_dependencies(
+ self: "CmdFile", target_path: PathStr, obj_tree: PathStr, fail_on_unknown_build_command: bool
+ ) -> list[PathStr]:
+ """
+ Parses all dependencies required to build a target file from its cmd file.
+
+ Args:
+ target_path: path to the target file relative to `obj_tree`.
+ obj_tree: absolute path to the object tree.
+ fail_on_unknown_build_command: Whether to fail if an unknown build command is encountered.
+
+ Returns:
+ list[PathStr]: dependency file paths relative to `obj_tree`.
+ """
+ input_files: list[PathStr] = [
+ str(p) for p in parse_inputs_from_commands(self.savedcmd, fail_on_unknown_build_command)
+ ]
+ if self.deps:
+ input_files += [str(p) for p in parse_cmd_file_deps(self.deps)]
+ input_files = _expand_resolve_files(input_files, obj_tree)
+
+ cmd_file_dependencies: list[PathStr] = []
+ for input_file in input_files:
+ # input files are either absolute or relative to the object tree
+ if os.path.isabs(input_file):
+ input_file = os.path.relpath(input_file, obj_tree)
+ if input_file == target_path:
+ # Skip target file to prevent cycles. This is necessary because some multi stage commands first create an output and then pass it as input to the next command, e.g., objcopy.
+ continue
+ cmd_file_dependencies.append(input_file)
+ unique_cmd_file_dependencies = list(dict.fromkeys(cmd_file_dependencies))
+ return unique_cmd_file_dependencies
+
+
+def _expand_resolve_files(input_files: list[PathStr], obj_tree: PathStr) -> list[PathStr]:
+ """
+ Expands resolve files which may reference additional files via '@' notation.
+
+ Args:
+ input_files (list[PathStr]): List of file paths relative to the object tree, where paths starting with '@' refer to files
+ containing further file paths, each on a separate line.
+ obj_tree: Absolute path to the root of the object tree.
+
+ Returns:
+ list[PathStr]: Flattened list of all input file paths, with any nested '@' file references resolved recursively.
+ """
+ expanded_input_files: list[PathStr] = []
+ for input_file in input_files:
+ if not input_file.startswith("@"):
+ expanded_input_files.append(input_file)
+ continue
+ resolve_file_path = os.path.join(obj_tree, input_file.removeprefix("@"))
+ if not os.path.exists(resolve_file_path):
+ sbom_logging.error(
+ "Skip resolving '{resolve_file_path}' because the response file does not exist.",
+ resolve_file_path=resolve_file_path,
+ )
+ continue
+ with open(resolve_file_path, "rt", encoding="utf-8") as f:
+ resolve_file_content = [line_stripped for line in f.readlines() if (line_stripped := line.strip())]
+ expanded_input_files += _expand_resolve_files(resolve_file_content, obj_tree)
+ return expanded_input_files
diff --git a/scripts/sbom/sbom/cmd_graph/cmd_graph.py b/scripts/sbom/sbom/cmd_graph/cmd_graph.py
new file mode 100644
index 000000000000..2f57965237f4
--- /dev/null
+++ b/scripts/sbom/sbom/cmd_graph/cmd_graph.py
@@ -0,0 +1,46 @@
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+from collections import deque
+from dataclasses import dataclass, field
+from typing import Iterator
+
+from sbom.cmd_graph.cmd_graph_node import CmdGraphNode, CmdGraphNodeConfig
+from sbom.path_utils import PathStr
+
+
+@dataclass
+class CmdGraph:
+ """Directed acyclic graph of build dependencies primarily inferred from .cmd files produced during kernel builds"""
+
+ roots: list[CmdGraphNode] = field(default_factory=list)
+
+ @classmethod
+ def create(cls, root_paths: list[PathStr], config: CmdGraphNodeConfig) -> "CmdGraph":
+ """
+ Recursively builds a dependency graph starting from `root_paths`.
+ Dependencies are mainly discovered by parsing the `.cmd` files.
+
+ Args:
+ root_paths (list[PathStr]): List of paths to root outputs relative to obj_tree
+ config (CmdGraphNodeConfig): Configuration options
+
+ Returns:
+ CmdGraph: A graph of all build dependencies for the given root files.
+ """
+ node_cache: dict[PathStr, CmdGraphNode] = {}
+ root_nodes = [CmdGraphNode.create(root_path, config, node_cache) for root_path in root_paths]
+ return CmdGraph(root_nodes)
+
+ def __iter__(self) -> Iterator[CmdGraphNode]:
+ """Traverse the graph in breadth-first order, yielding each unique node."""
+ visited: set[PathStr] = set()
+ node_stack: deque[CmdGraphNode] = deque(self.roots)
+ while len(node_stack) > 0:
+ node = node_stack.popleft()
+ if node.absolute_path in visited:
+ continue
+
+ visited.add(node.absolute_path)
+ node_stack.extend(node.children)
+ yield node
diff --git a/scripts/sbom/sbom/cmd_graph/cmd_graph_node.py b/scripts/sbom/sbom/cmd_graph/cmd_graph_node.py
new file mode 100644
index 000000000000..61f3a8140cea
--- /dev/null
+++ b/scripts/sbom/sbom/cmd_graph/cmd_graph_node.py
@@ -0,0 +1,142 @@
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+from dataclasses import dataclass, field
+from itertools import chain
+import logging
+import os
+from typing import Iterator, Protocol
+
+from sbom import sbom_logging
+from sbom.cmd_graph.cmd_file import CmdFile
+from sbom.cmd_graph.hardcoded_dependencies import get_hardcoded_dependencies
+from sbom.cmd_graph.incbin_parser import parse_incbin_statements
+from sbom.path_utils import PathStr, has_link, is_relative_to
+
+
+@dataclass
+class IncbinDependency:
+ node: "CmdGraphNode"
+ full_statement: str
+
+
+class CmdGraphNodeConfig(Protocol):
+ obj_tree: PathStr
+ src_tree: PathStr
+ fail_on_unknown_build_command: bool
+
+
+@dataclass
+class CmdGraphNode:
+ """A node in the cmd graph representing a single file and its dependencies."""
+
+ absolute_path: PathStr
+ """Absolute path to the file this node represents."""
+
+ cmd_file: CmdFile | None = None
+ """Parsed .cmd file describing how the file at absolute_path was built, or None if not available."""
+
+ cmd_file_dependencies: list["CmdGraphNode"] = field(default_factory=list)
+ incbin_dependencies: list[IncbinDependency] = field(default_factory=list)
+ hardcoded_dependencies: list["CmdGraphNode"] = field(default_factory=list)
+
+ @property
+ def children(self) -> Iterator["CmdGraphNode"]:
+ seen: set[PathStr] = set()
+ for node in chain(
+ self.cmd_file_dependencies,
+ (dep.node for dep in self.incbin_dependencies),
+ self.hardcoded_dependencies,
+ ):
+ if node.absolute_path not in seen:
+ seen.add(node.absolute_path)
+ yield node
+
+ @classmethod
+ def create(
+ cls,
+ target_path: PathStr,
+ config: CmdGraphNodeConfig,
+ cache: dict[PathStr, "CmdGraphNode"] | None = None,
+ depth: int = 0,
+ ) -> "CmdGraphNode":
+ """
+ Recursively builds a dependency graph starting from `target_path`.
+ Dependencies are mainly discovered by parsing the `.<target_path.name>.cmd` file.
+
+ Args:
+ target_path: Path to the target file relative to obj_tree.
+ config: Config options
+ cache: Tracks processed nodes to prevent cycles.
+ depth: Internal parameter to track the current recursion depth.
+
+ Returns:
+ CmdGraphNode: cmd graph node representing the target file
+ """
+ if cache is None:
+ cache = {}
+
+ target_path_absolute = (
+ os.path.realpath(p)
+ if has_link(p:=os.path.join(config.obj_tree, target_path))
+ else os.path.normpath(p)
+ )
+
+ if target_path_absolute in cache:
+ return cache[target_path_absolute]
+
+ if depth == 0:
+ logging.debug(f"Build node: {target_path}")
+
+ cmd_file_path = _to_cmd_path(target_path_absolute)
+ cmd_file = CmdFile.create(cmd_file_path) if os.path.exists(cmd_file_path) else None
+ node = CmdGraphNode(target_path_absolute, cmd_file)
+ cache[target_path_absolute] = node
+
+ if not os.path.exists(target_path_absolute):
+ error_or_warning = (
+ sbom_logging.error
+ if is_relative_to(target_path_absolute, config.obj_tree)
+ or is_relative_to(target_path_absolute, config.src_tree)
+ else sbom_logging.warning
+ )
+ error_or_warning(
+ "Skip parsing '{target_path_absolute}' because file does not exist",
+ target_path_absolute=target_path_absolute,
+ )
+ return node
+
+ # Search for dependencies to add to the graph as child nodes. Child paths are always relative to the output tree.
+ def _build_child_node(child_path: PathStr) -> "CmdGraphNode":
+ return CmdGraphNode.create(child_path, config, cache, depth + 1)
+
+ node.hardcoded_dependencies = [
+ _build_child_node(hardcoded_dependency_path)
+ for hardcoded_dependency_path in get_hardcoded_dependencies(
+ target_path_absolute, config.obj_tree, config.src_tree
+ )
+ ]
+
+ if cmd_file is not None:
+ node.cmd_file_dependencies = [
+ _build_child_node(cmd_file_dependency_path)
+ for cmd_file_dependency_path in cmd_file.get_dependencies(
+ target_path, config.obj_tree, config.fail_on_unknown_build_command
+ )
+ ]
+
+ if node.absolute_path.endswith(".S"):
+ node.incbin_dependencies = [
+ IncbinDependency(
+ node=_build_child_node(incbin_statement.path),
+ full_statement=incbin_statement.full_statement,
+ )
+ for incbin_statement in parse_incbin_statements(node.absolute_path)
+ ]
+
+ return node
+
+
+def _to_cmd_path(path: PathStr) -> PathStr:
+ name = os.path.basename(path)
+ return path.removesuffix(name) + f".{name}.cmd"
diff --git a/scripts/sbom/sbom/cmd_graph/deps_parser.py b/scripts/sbom/sbom/cmd_graph/deps_parser.py
new file mode 100644
index 000000000000..6a2d92f0778c
--- /dev/null
+++ b/scripts/sbom/sbom/cmd_graph/deps_parser.py
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+import re
+import sbom.sbom_logging as sbom_logging
+from sbom.path_utils import PathStr
+
+# Match dependencies on config files
+# Example match: "$(wildcard include/config/CONFIG_SOMETHING)"
+CONFIG_PATTERN = re.compile(r"\$\(wildcard (include/config/[^)]+)\)")
+
+# Match dependencies on the objtool binary
+# Example match: "$(wildcard ./tools/objtool/objtool)"
+OBJTOOL_PATTERN = re.compile(r"\$\(wildcard \./tools/objtool/objtool\)")
+
+# Match any Makefile wildcard reference
+# Example match: "$(wildcard path/to/file)"
+WILDCARD_PATTERN = re.compile(r"\$\(wildcard (?P<path>[^)]+)\)")
+
+# Match ordinary paths:
+# - ^(\/)?: Optionally starts with a '/'
+# - (([\w\-\.,+~=@ ]*)\/)*: Zero or more directory levels
+# - [\w\-\.,+~=@ ]+$: Path component (file or directory)
+# Example matches: "/foo/bar.c", "dir1/dir2/file.txt", "plainfile"
+VALID_PATH_PATTERN = re.compile(r"^(\/)?(([\w\-\.,+~=@ ]*)\/)*[\w\-\.,+~=@ ]+$")
+
+
+def parse_cmd_file_deps(deps: list[str]) -> list[PathStr]:
+ """
+ Parse dependency strings of a .cmd file and return valid input file paths.
+
+ Args:
+ deps: List of dependency strings as found in `.cmd` files.
+
+ Returns:
+ input_files: List of input file paths
+ """
+ input_files: list[PathStr] = []
+ for dep in deps:
+ dep = dep.strip()
+ match dep:
+ case _ if CONFIG_PATTERN.match(dep) or OBJTOOL_PATTERN.match(dep):
+ # config paths like include/config/<CONFIG_NAME> should not be included in the graph
+ continue
+ case _ if match := WILDCARD_PATTERN.match(dep):
+ path = match.group("path")
+ input_files.append(path)
+ case _ if VALID_PATH_PATTERN.match(dep):
+ input_files.append(dep)
+ case _:
+ sbom_logging.error("Skip parsing dependency {dep} because of unrecognized format", dep=dep)
+ return input_files
diff --git a/scripts/sbom/sbom/cmd_graph/hardcoded_dependencies.py b/scripts/sbom/sbom/cmd_graph/hardcoded_dependencies.py
new file mode 100644
index 000000000000..2eb04d30f4e6
--- /dev/null
+++ b/scripts/sbom/sbom/cmd_graph/hardcoded_dependencies.py
@@ -0,0 +1,87 @@
+# SPDX-License-Identifier: GPL-2.0-only OR MIT
+# Copyright (C) 2025 TNG Technology Consulting GmbH
+
+import os
+from typing import Callable
+import sbom.sbom_logging as sbom_logging
+from sbom.path_utils import PathStr, is_relative_to
+from sbom.environment import Environment
+
+HARDCODED_DEPENDENCIES: dict[str, list[str]] = {
+ # defined in linux/Kbuild
+ "include/generated/rq-offsets.h": ["kernel/sched/rq-offsets.s"],
+ "kernel/sched/rq-offsets.s": ["include/generated/asm-offsets.h"],
+ "include/generated/bounds.h": ["kernel/bounds.s"],
+ "include/generated/asm-offsets.h": ["arch/{arch}/kernel/asm-offsets.s"],
+}
+"""
+Maps file paths to the list of dependencies required to build them
+which are not tracked by the .cmd dependency mechanism.
+Paths are relative to either the source tree or the object tree.
+"""
+
+def get_hardcoded_dependencies(path: PathStr, obj_tree: PathStr, src_tree: PathStr) -> list[PathStr]:
+ """
+ Some files in the kernel build process are not tracked by the .cmd dependency mechanism.
+ Parsing these depende