Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,76 @@ sbom(
"@score_crates//:MODULE.bazel.lock",
":MODULE.bazel.lock",
],
python_lockfiles = ["//path/to:requirements.txt.lock"],
auto_crates_cache = True,
auto_cdxgen = True,
)
```

## SBOM modes

The rule supports two complementary SBOM modes. The modes are selected by the
consumer's `BUILD` file; they are not separate Bazel rules.

### Product mode

Product mode describes software delivered by the project. Pass the product or
runtime binaries and libraries in `targets`, and enable the collectors needed
for their shipped dependencies. Product SBOMs commonly emit both SPDX and
CycloneDX:

```starlark
sbom(
name = "product_sbom",
targets = ["//app:runtime_binary"],
component_name = "my_product",
output_formats = ["spdx", "cyclonedx"],
module_lockfiles = [":MODULE.bazel.lock"],
auto_cdxgen = True,
auto_crates_cache = True,
)
```

### Tool qualification mode

Tool mode describes software used to build, test, or generate documentation.
It is kept separate from product mode because these components are not product
runtime dependencies. Pass build-tool and documentation targets, use SPDX for
the qualification inventory when required, and add the lockfiles and files
used by those tools:

```starlark
sbom(
name = "build_tools_sbom",
targets = [
"//:docs",
"//tools:build_tool",
"@docs_as_code//:plantuml",
],
component_name = "my_product_build_tools",
output_formats = ["spdx"],
auto_cdxgen = False,
auto_crates_cache = False,
python_lockfiles = [
"//tools:requirements.txt.lock",
"@docs_as_code//:requirements_lock",
],
java_files = ["@docs_as_code//:plantuml.jar"],
exclude_patterns = ["rules_python++pip+"],
)
```

The Python collector reads pinned pip-compile lockfiles and emits PyPI
components with versions and SHA-256 hashes. The Java collector inventories
declared `.jar` or other Java files as file-level components, including the
file name, size, and SHA-256 checksum. This is useful for tools such as
PlantUML, where the Python integration and the Java JAR are separate artifacts.

The host Java runtime itself (for example, the `java` executable installed by
the operating system) is not a Bazel file input and therefore is not collected
by `java_files`; it must be supplied through a separate host-tool inventory if
the qualification scope requires the JDK installation as well.

### Parameters

| Parameter | Default | Description |
Expand All @@ -51,6 +116,9 @@ sbom(
| `component_name` | rule `name` | Name of the root component written into the SBOM; defaults to the rule name if omitted. |
| `component_version` | `None` | Version string for the root component; auto-detected from the module graph when omitted. |
| `module_lockfiles` | `[]` | One or more `MODULE.bazel.lock` files used to extract dependency versions and SHA-256 checksums; C++ projects need only the workspace lockfile (`:MODULE.bazel.lock`), Rust projects should also pass `@score_crates//:MODULE.bazel.lock` to cover crate versions and checksums. |
| `python_lockfiles` | `[]` | One or more pip-compile lockfiles (`requirements.txt.lock`) used to add pinned PyPI packages, SHA-256 hashes, and license expressions from DASH. Packages that DASH cannot verify retain `NOASSERTION`; descriptions are not enriched yet and remain `Missing`. |
| `auto_python_cache` | `True` | Generates Python package metadata from `python_lockfiles`; set to `False` to disable it. |
| `java_files` | `[]` | Java or JAR files to inventory as file-level components. Each file gets its name, size, and SHA-256 checksum; this does not inventory the host Java runtime. |
| `auto_crates_cache` | `True` | Runs `generate_crates_metadata_cache` at build time (requires network) to fetch Rust crate license and supplier data from dash-license-scan and crates.io; set to `False` only as a workaround for air-gapped or offline build environments — doing so produces a non-compliant SBOM where all Rust crates show `NOASSERTION` for license, supplier, and description. Has no effect when no lockfiles are provided (pure C++ projects). |
| `cargo_lockfile` | `None` | Path to a `Cargo.lock` file for crate enumeration; not needed when `module_lockfiles` is provided, as a synthetic `Cargo.lock` is generated from it automatically. **Deprecated — will be removed in a future release.** |
| `cdxgen_sbom` | `None` | Label to a pre-generated cdxgen CycloneDX JSON file; alternative to `auto_cdxgen` for C++ projects where cdxgen cannot run inside the Bazel build (e.g. CI environment without npm). Run cdxgen manually and pass its output here. Ignored for pure Rust projects. |
Expand Down Expand Up @@ -137,13 +205,13 @@ Generated in `bazel-bin/`:
**Data sources:**
- **Bazel module graph** — version, PURL, and registry info for `bazel_dep` modules
- **Bazel aspect** — transitive dependency graph and external repo dependency edges
- **dash-license-scan** — licenses data
- **dash-license-scan** — Rust and Python license data from the Eclipse Foundation and ClearlyDefined services
- **crates.io API** — description and supplier for Rust crates
- **cdxgen** — C++ dependency licenses, descriptions, and suppliers

### Automated Metadata Sources

All license, hash, supplier, and description values are derived from automated sources: `MODULE.bazel.lock`, `http_archive` rules, dash-license-scan (Rust), crates.io API (Rust), and cdxgen (C++). Cache files such as `cpp_metadata.json` must never be hand-edited.
All license, hash, supplier, and description values are derived from automated sources: `MODULE.bazel.lock`, `http_archive` rules, dash-license-scan (Rust and Python), crates.io API (Rust), and cdxgen (C++). Cache files such as `cpp_metadata.json` must never be hand-edited.

CPE, aliases, and pedigree are the only fields that may be set manually via `sbom_ext.license()`, as they represent identity and provenance annotations that cannot be auto-deduced.

Expand Down Expand Up @@ -177,6 +245,7 @@ Only transitive dependencies of the declared build targets are included. Build-t
### License Data by Language

- **Rust**: Licenses via dash-license-scan (Eclipse Foundation + ClearlyDefined); descriptions and suppliers from crates.io API. Crates with platform-specific suffixes (e.g. `iceoryx2-bb-lock-free-qnx8`) fall back to the base crate name for lookup.
- **Python**: Licenses via dash-license-scan (Eclipse Foundation + ClearlyDefined), using `pypi/pypi/-/<name>/<version>` identifiers generated from pip-compile lockfiles. Descriptions and suppliers are not enriched yet.
- **C++**: Licenses, descriptions, and suppliers via cdxgen source tree scan. There is no dash-license-scan integration for C++ — it does not support `pkg:generic/...` PURLs used by BCR modules. If cdxgen cannot resolve a component, its description is set to `"Missing"` and its license field is empty.

### Output Format Versions
Expand Down
11 changes: 11 additions & 0 deletions defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ def sbom(
auto_cdxgen = False,
cargo_lockfile = None,
module_lockfiles = None,
python_lockfiles = None,
java_files = None,
testonly = False,
auto_python_cache = True,
auto_crates_cache = True,
output_formats = ["spdx", "cyclonedx"],
producer_name = "Eclipse Foundation",
Expand Down Expand Up @@ -79,6 +83,9 @@ def sbom(
auto_cdxgen: Run cdxgen automatically when no cdxgen_sbom is provided
cargo_lockfile: Optional Cargo.lock for crates metadata cache generation
module_lockfiles: MODULE.bazel.lock files for crate metadata extraction (e.g., from score_crates and workspace)
python_lockfiles: pip-compile requirements lockfiles for Python package metadata.
java_files: Java/JAR files to inventory as file-level components.
auto_python_cache: Run Python metadata collection when python_lockfiles are provided
auto_crates_cache: Run crates metadata cache generation when cargo_lockfile or module_lockfiles is provided
output_formats: List of formats to generate ("spdx", "cyclonedx")
producer_name: SBOM producer organization name
Expand Down Expand Up @@ -138,6 +145,10 @@ def sbom(
auto_cdxgen = auto_cdxgen,
cargo_lockfile = cargo_lockfile,
module_lockfiles = module_lockfiles if module_lockfiles else [],
python_lockfiles = python_lockfiles if python_lockfiles else [],
java_files = java_files if java_files else [],
testonly = testonly,
auto_python_cache = auto_python_cache,
auto_crates_cache = auto_crates_cache,
output_formats = output_formats,
producer_name = producer_name,
Expand Down
45 changes: 43 additions & 2 deletions internal/generator/sbom_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"""

import argparse
import hashlib
import json
import re
import sys
Expand Down Expand Up @@ -164,6 +165,34 @@ def load_crates_cache(cache_path: str | None = None) -> dict[str, Any]:
return {}


def collect_java_file_components(file_paths: list[str]) -> list[dict[str, Any]]:
"""Create file-level components for declared Java and JAR artifacts."""
components = []
for file_path in file_paths:
path = Path(file_path)
try:
size = path.stat().st_size
digest_hash = hashlib.sha256()
with path.open("rb") as artifact:
for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
digest_hash.update(chunk)
digest = digest_hash.hexdigest()
except OSError:
continue
components.append(
{
"name": f"{path.name}-{digest[:12]}",
"version": "file",
"type": "file",
"source": "java",
"url": "NOASSERTION",
"checksum": digest,
"description": f"Java artifact {path.name} ({size} bytes)",
}
)
return components


# Known licenses for Bazel Central Registry (BCR) C++ modules.
# Used as a fallback when cdxgen and lockfile parsing cannot provide license data.
# Keys are BCR module names (exact or prefix for sub-modules like boost.*).
Expand Down Expand Up @@ -410,6 +439,7 @@ def main() -> int:
parser.add_argument("--spdx-output", help="SPDX 2.3 JSON output file")
parser.add_argument("--cyclonedx-output", help="CycloneDX 1.6 output file")
parser.add_argument("--crates-cache", help="Path to crates_metadata.json override")
parser.add_argument("--python-cache", help="Path to Python package metadata cache")
parser.add_argument(
"--cdxgen-sbom",
help="Path to cdxgen-generated CycloneDX JSON for C++ enrichment",
Expand Down Expand Up @@ -451,13 +481,24 @@ def main() -> int:
# Load crates metadata cache (licenses + checksums + versions)
crates_cache = load_crates_cache(args.crates_cache)

python_cache = {}
if args.python_cache:
try:
with open(args.python_cache, encoding="utf-8") as f:
python_cache = json.load(f)
except (OSError, json.JSONDecodeError):
python_cache = {}

# Add crates cache to metadata
if crates_cache:
if "crates" not in metadata:
metadata["crates"] = {}
for name, cache_data in crates_cache.items():
metadata["crates"].setdefault(name, cache_data)

components = list(python_cache.values())
components.extend(collect_java_file_components(data.get("java_files", [])))

# Apply BCR known licenses and user overrides to modules
apply_known_licenses(metadata)

Expand All @@ -472,7 +513,7 @@ def main() -> int:
filtered_repos = filter_repos(external_repos, exclude_patterns)

# Build component list with metadata
components = []
components.extend([])

for repo in filtered_repos:
component = resolve_component(repo, metadata)
Expand Down Expand Up @@ -502,7 +543,7 @@ def main() -> int:
# (from an edge destination) are not treated as distinct components — both
# would otherwise produce the same sanitised bom-ref, creating duplicates.
existing_names = {c.get("name", "").rstrip("+") for c in components}
for dst in sorted(edge_dst_repos):
for dst in filter_repos(sorted(edge_dst_repos), exclude_patterns):
if dst.rstrip("+") not in existing_names:
component = resolve_component(dst, metadata)
if component:
Expand Down
43 changes: 42 additions & 1 deletion internal/rules.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ def _sbom_impl(ctx):
# Collect MODULE.bazel files from dependency modules for version extraction
dep_module_paths = [f.path for f in ctx.files.dep_module_files]
module_lock_paths = [f.path for f in ctx.files.module_lockfiles]
python_lock_paths = [f.path for f in ctx.files.python_lockfiles]
java_file_paths = [f.path for f in ctx.files.java_files]

deps_data = {
"external_repos": all_external_repos.to_list(),
Expand All @@ -85,6 +87,8 @@ def _sbom_impl(ctx):
"exclude_patterns": exclude_patterns,
"dep_module_files": dep_module_paths,
"module_lockfiles": module_lock_paths,
"python_lockfiles": python_lock_paths,
"java_files": java_file_paths,
"config": {
"producer_name": ctx.attr.producer_name,
"producer_url": ctx.attr.producer_url,
Expand Down Expand Up @@ -119,7 +123,7 @@ def _sbom_impl(ctx):
args.add("--cyclonedx-output", cdx_out)

# Build inputs list
generator_inputs = [deps_json, metadata_file] + ctx.files.dep_module_files + ctx.files.module_lockfiles
generator_inputs = [deps_json, metadata_file] + ctx.files.dep_module_files + ctx.files.module_lockfiles + ctx.files.java_files

Comment on lines 125 to 127
# Auto-generate crates metadata cache if enabled and a lockfile is provided
crates_cache = None
Expand Down Expand Up @@ -180,6 +184,24 @@ def _sbom_impl(ctx):
args.add("--crates-cache", crates_cache)
generator_inputs.append(crates_cache)

python_cache = None
if ctx.files.python_lockfiles and ctx.attr.auto_python_cache:
python_cache = ctx.actions.declare_file(ctx.attr.name + "_python_metadata.json")
ctx.actions.run(
inputs = ctx.files.python_lockfiles,
outputs = [python_cache],
executable = ctx.executable._python_cache,
arguments = [python_cache.path] + [f.path for f in ctx.files.python_lockfiles],
mnemonic = "PythonMetadataGenerate",
progress_message = "Generating Python metadata cache for %s" % ctx.attr.name,
execution_requirements = {"requires-network": ""},
use_default_shell_env = True,
)

if python_cache:
args.add("--python-cache", python_cache)
generator_inputs.append(python_cache)

# Run Python generator
ctx.actions.run(
inputs = generator_inputs,
Expand Down Expand Up @@ -268,6 +290,20 @@ sbom_rule = rule(
allow_files = True,
doc = "MODULE.bazel.lock files for crate metadata extraction (e.g., from score_crates and workspace)",
),
"python_lockfiles": attr.label_list(
allow_files = True,
default = [],
doc = "pip-compile requirements lockfiles for Python metadata extraction",
),
"java_files": attr.label_list(
allow_files = True,
default = [],
doc = "Java/JAR files to inventory as file-level components",
),
"auto_python_cache": attr.bool(
default = True,
doc = "Automatically collect Python package metadata from lockfiles",
),
"cdxgen_sbom": attr.label(
allow_single_file = [".json"],
doc = "Optional CycloneDX JSON from cdxgen for C++ dependency enrichment",
Expand All @@ -289,6 +325,11 @@ sbom_rule = rule(
default = "//scripts:generate_crates_metadata_cache.py",
allow_single_file = True,
),
"_python_cache": attr.label(
default = "//scripts:generate_python_metadata_cache",
executable = True,
cfg = "exec",
),
"_generator": attr.label(
default = "//internal/generator:sbom_generator",
executable = True,
Expand Down
12 changes: 12 additions & 0 deletions scripts/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,25 @@ package(default_visibility = ["//visibility:public"])

exports_files([
"generate_crates_metadata_cache.py",
"generate_python_metadata_cache.py",
])

py_library(
name = "generate_crates_metadata_cache",
srcs = ["generate_crates_metadata_cache.py"],
)

py_binary(
name = "generate_python_metadata_cache",
srcs = ["generate_python_metadata_cache.py"],
main = "generate_python_metadata_cache.py",
)

py_library(
name = "generate_python_metadata_cache_lib",
srcs = ["generate_python_metadata_cache.py"],
)

py_library(
name = "generate_cpp_metadata_cache",
srcs = ["generate_cpp_metadata_cache.py"],
Expand Down
Loading
Loading