You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by ar...@apache.org on 2026/06/04 14:55:14 UTC
(tooling-trusted-releases) 03/03: Some SBOM refactoring and correct author reporting issue based on 1.7 spec.
This is an automated email from the ASF dual-hosted git repository.
alitheg pushed a commit to branch arm
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
commit 45264867a2242e9d7118b1bfdfaf9565435ff52f
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jun 4 15:54:50 2026 +0100
Some SBOM refactoring and correct author reporting issue based on 1.7 spec.
---
atr/sbom/cli.py | 9 ++--
atr/sbom/conformance.py | 58 +++++---------------------
atr/sbom/constants/__init__.py | 3 +-
atr/sbom/constants/conformance.py | 7 ++++
atr/sbom/constants/version.py | 22 ----------
atr/sbom/models/conformance.py | 2 +-
atr/sbom/utilities.py | 86 ++++++++++++++++++++++++++-------------
atr/tasks/sbom.py | 9 +++-
typestubs/cyclonedx/model/bom.pyi | 1 +
9 files changed, 90 insertions(+), 107 deletions(-)
diff --git a/atr/sbom/cli.py b/atr/sbom/cli.py
index 48e66150..220688b4 100644
--- a/atr/sbom/cli.py
+++ b/atr/sbom/cli.py
@@ -31,7 +31,7 @@ from .cyclonedx import validate_cli, validate_py
from .licenses import check
from .sbomqs import total_score
from .tool import plugin_outdated_version
-from .utilities import bundle_to_ntia_patch, bundle_to_vuln_patch, patch_to_data, path_to_bundle
+from .utilities import bundle_to_ntia_patch, bundle_to_vuln_patch, patch_document, patch_to_data, path_to_bundle
def command_license(bundle: models.bundle.Bundle) -> None:
@@ -58,10 +58,10 @@ def command_license(bundle: models.bundle.Bundle) -> None:
def command_merge(bundle: models.bundle.Bundle) -> None:
+ # TODO: This bypasses apply_patch, so it doesn't record the ATR tool or bump the version
patch_ops = asyncio.run(bundle_to_ntia_patch(bundle))
if patch_ops:
- patch_data = patch_to_data(patch_ops)
- output = bundle.doc.patch(yyjson.Document(patch_data))
+ output = patch_document(bundle.doc, patch_ops)
else:
output = bundle.doc
if bundle.source_type == "json":
@@ -124,8 +124,7 @@ def command_patch_vuln(bundle: models.bundle.Bundle) -> None:
def command_scores(bundle: models.bundle.Bundle) -> None:
patch_ops = asyncio.run(bundle_to_ntia_patch(bundle))
if patch_ops:
- patch_data = patch_to_data(patch_ops)
- merged = bundle.doc.patch(yyjson.Document(patch_data))
+ merged = patch_document(bundle.doc, patch_ops)
print(total_score(bundle.doc), "->", total_score(merged))
else:
print(total_score(bundle.doc))
diff --git a/atr/sbom/conformance.py b/atr/sbom/conformance.py
index 9f18ee19..84289c73 100644
--- a/atr/sbom/conformance.py
+++ b/atr/sbom/conformance.py
@@ -26,7 +26,7 @@ import yyjson
from . import constants, models
from .maven import cache_read, cache_write
-from .utilities import get_pointer
+from .utilities import ensure_metadata, get_pointer
if TYPE_CHECKING:
from cyclonedx.model.component import Component
@@ -76,7 +76,7 @@ async def assemble_component_supplier(
add_asf_op = make_supplier_op(
constants.conformance.THE_APACHE_SOFTWARE_FOUNDATION,
- "https://apache.org/",
+ constants.conformance.APACHE_URL,
)
if get_pointer(doc, f"/components/{index}/publisher") == constants.conformance.THE_APACHE_SOFTWARE_FOUNDATION:
@@ -186,40 +186,12 @@ def assemble_dependencies(doc: yyjson.Document, patch_ops: models.patch.Patch) -
def assemble_metadata(doc: yyjson.Document, patch_ops: models.patch.Patch) -> None:
- if get_pointer(doc, "/metadata") is None:
- patch_ops.append(
- models.patch.AddOp(
- op="add",
- path="/metadata",
- value={},
- )
- )
+ ensure_metadata(doc, patch_ops)
def assemble_metadata_author(doc: yyjson.Document, patch_ops: models.patch.Patch) -> None:
- assemble_metadata(doc, patch_ops)
- tools = get_pointer(doc, "/metadata/tools")
- tool = {
- "name": "sbomtool",
- "version": constants.version.VERSION,
- "description": "By ASF Tooling",
- }
- if tools is None:
- patch_ops.append(
- models.patch.AddOp(
- op="add",
- path="/metadata/tools",
- value=[tool],
- )
- )
- elif isinstance(tools, list):
- patch_ops.append(
- models.patch.AddOp(
- op="add",
- path="/metadata/tools/-",
- value=tool,
- )
- )
+ # We can't synthesise an author here
+ pass
def assemble_metadata_component(doc: yyjson.Document, patch_ops: models.patch.Patch) -> None:
@@ -231,16 +203,7 @@ def assemble_metadata_component(doc: yyjson.Document, patch_ops: models.patch.Pa
def assemble_metadata_supplier(doc: yyjson.Document, patch_ops: models.patch.Patch) -> None:
assemble_metadata(doc, patch_ops)
# NOTE: The sbomqs tool requires a URL (or email) on a supplier
- patch_ops.append(
- models.patch.AddOp(
- op="add",
- path="/metadata/supplier",
- value={
- "name": constants.conformance.THE_APACHE_SOFTWARE_FOUNDATION,
- "url": ["https://apache.org/"],
- },
- )
- )
+ patch_ops.append(models.patch.AddOp(op="add", path="/metadata/supplier", value=constants.conformance.ASF_ENTITY))
def assemble_metadata_timestamp(doc: yyjson.Document, patch_ops: models.patch.Patch) -> None:
@@ -323,8 +286,10 @@ def ntia_2021_issues(
else:
errors.append(models.conformance.MissingProperty(property=models.conformance.Property.METADATA_COMPONENT))
- if len(bom_value.metadata.authors) < 1:
- errors.append(models.conformance.MissingProperty(property=models.conformance.Property.METADATA_AUTHOR))
+ if len(bom_value.metadata.authors) < 1 and bom_value.metadata.manufacturer is None:
+ errors.append(
+ models.conformance.MissingProperty(property=models.conformance.Property.METADATA_AUTHOR_OR_MANUFACTURER)
+ )
if original_metadata.get("timestamp") is None:
errors.append(models.conformance.MissingProperty(property=models.conformance.Property.METADATA_TIMESTAMP))
else:
@@ -388,7 +353,6 @@ async def ntia_2021_patch(
errors: list[models.conformance.Missing],
) -> models.patch.Patch:
patch_ops: models.patch.Patch = []
- # TODO: Add tool metadata
for error in errors:
match error:
case models.conformance.MissingProperty(property=property_value):
@@ -399,7 +363,7 @@ async def ntia_2021_patch(
assemble_metadata(doc, patch_ops)
case models.conformance.Property.METADATA_COMPONENT:
assemble_metadata_component(doc, patch_ops)
- case models.conformance.Property.METADATA_AUTHOR:
+ case models.conformance.Property.METADATA_AUTHOR_OR_MANUFACTURER:
assemble_metadata_author(doc, patch_ops)
case models.conformance.Property.METADATA_TIMESTAMP:
assemble_metadata_timestamp(doc, patch_ops)
diff --git a/atr/sbom/constants/__init__.py b/atr/sbom/constants/__init__.py
index 0ce8e553..3f048bad 100644
--- a/atr/sbom/constants/__init__.py
+++ b/atr/sbom/constants/__init__.py
@@ -17,12 +17,11 @@
from __future__ import annotations
-from . import conformance, licenses, maven, spdx, version
+from . import conformance, licenses, maven, spdx
__all__ = [
"conformance",
"licenses",
"maven",
"spdx",
- "version",
]
diff --git a/atr/sbom/constants/conformance.py b/atr/sbom/constants/conformance.py
index 7883457f..dcdc5624 100644
--- a/atr/sbom/constants/conformance.py
+++ b/atr/sbom/constants/conformance.py
@@ -36,3 +36,10 @@ KNOWN_PURL_SUPPLIERS: Final[dict[tuple[str, str], tuple[str, str]]] = {
}
THE_APACHE_SOFTWARE_FOUNDATION: Final[str] = "The Apache Software Foundation"
+
+APACHE_URL: Final[str] = "https://apache.org/"
+
+ASF_ENTITY: Final[dict[str, str | list[str]]] = {
+ "name": THE_APACHE_SOFTWARE_FOUNDATION,
+ "url": [APACHE_URL],
+}
diff --git a/atr/sbom/constants/version.py b/atr/sbom/constants/version.py
deleted file mode 100644
index 818c3c34..00000000
--- a/atr/sbom/constants/version.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-from __future__ import annotations
-
-from typing import Final
-
-VERSION: Final[str] = "0.0.1-dev1"
diff --git a/atr/sbom/models/conformance.py b/atr/sbom/models/conformance.py
index 7b02adb1..da16a930 100644
--- a/atr/sbom/models/conformance.py
+++ b/atr/sbom/models/conformance.py
@@ -29,7 +29,7 @@ class Property(enum.Enum):
METADATA = enum.auto()
METADATA_SUPPLIER = enum.auto()
METADATA_COMPONENT = enum.auto()
- METADATA_AUTHOR = enum.auto()
+ METADATA_AUTHOR_OR_MANUFACTURER = enum.auto()
METADATA_TIMESTAMP = enum.auto()
DEPENDENCIES = enum.auto()
diff --git a/atr/sbom/utilities.py b/atr/sbom/utilities.py
index 07a88b6a..c200f5b7 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -58,10 +58,7 @@ _SCORING_METHODS_CDX = {"CVSSv2": "CVSS_V2", "CVSSv3": "CVSS_V3", "CVSSv4": "CVS
_CDX_SEVERITIES = ["critical", "high", "medium", "low", "info", "none", "unknown"]
_TOOL_METADATA = {
"bom-ref": "tool:asf:atr",
- "provider": {
- "name": constants.conformance.THE_APACHE_SOFTWARE_FOUNDATION,
- "url": ["https://apache.org/"],
- },
+ "provider": constants.conformance.ASF_ENTITY,
"name": "Apache Trusted Releases",
"authenticated": True,
"version": _ATR_VERSION,
@@ -72,11 +69,59 @@ def apply_patch(
reason: str, revision: str, bundle: models.bundle.Bundle, patch_ops: models.patch.Patch
) -> tuple[int, yyjson.Document]:
"""Take a list of patch operations and apply them to the bundle. Returns the patched document, with the
- task recorded in `properties` and the SBOM version number incremented as per the CDX spec."""
+ task recorded in `properties` and the SBOM version number incremented as per the CDX spec.
+
+ With nothing to apply we leave the bundle alone - no task record, no tool, and no version bump."""
+ if not patch_ops:
+ return bundle.bom.version, bundle.doc
_record_task(reason, revision, bundle.doc, patch_ops)
new_version = _increment_version(bundle.doc, patch_ops)
- patch_data = patch_to_data(patch_ops)
- return new_version, bundle.doc.patch(yyjson.Document(patch_data))
+ return new_version, patch_document(bundle.doc, patch_ops)
+
+
+def ensure_metadata(doc: yyjson.Document, patch_ops: models.patch.Patch) -> None:
+ if get_pointer(doc, "/metadata") is not None:
+ return
+ # Patches are built against the original doc, so don't queue a second bootstrap that would clobber the first
+ if any((op.op == "add") and (op.path == "/metadata") for op in patch_ops):
+ return
+ patch_ops.append(models.patch.AddOp(op="add", path="/metadata", value={}))
+
+
+def record_atr_tool(doc: yyjson.Document, patch_ops: models.patch.Patch) -> models.patch.Patch:
+ """Ensure the ATR tool is recorded under metadata.tools.services, refreshing it if already present."""
+ ensure_metadata(doc, patch_ops)
+ tools: dict[str, Any] | None = get_pointer(doc, "/metadata/tools")
+ services: list[dict[str, Any]] | None = get_pointer(doc, "/metadata/tools/services")
+ if tools is None:
+ patch_ops.append(models.patch.AddOp(op="add", path="/metadata/tools", value={}))
+ if services is None:
+ services = []
+ patch_ops.append(models.patch.AddOp(op="add", path="/metadata/tools/services", value=services))
+ try:
+ tool_index = [s.get("bom-ref", "") for s in services].index("tool:asf:atr")
+ except ValueError:
+ tool_index = -1
+ if tool_index == -1:
+ patch_ops.append(
+ models.patch.AddOp(op="add", path=f"/metadata/tools/services/{len(services)}", value=_TOOL_METADATA)
+ )
+ else:
+ # If we ever add more metadata to this, such as task-specific properties under the tool, this changes
+ patch_ops.append(
+ models.patch.ReplaceOp(op="replace", path=f"/metadata/tools/services/{tool_index}", value=_TOOL_METADATA)
+ )
+ return patch_ops
+
+
+def record_manufacturer(doc: yyjson.Document, patch_ops: models.patch.Patch) -> models.patch.Patch:
+ """Record ASF as the BOM manufacturer - the organisation that produced it."""
+ # Generation-only - we deliberately don't stamp this in the ntia_2021_patch conformance flow
+ ensure_metadata(doc, patch_ops)
+ patch_ops.append(
+ models.patch.AddOp(op="add", path="/metadata/manufacturer", value=constants.conformance.ASF_ENTITY)
+ )
+ return patch_ops
async def bundle_to_ntia_patch(bundle_value: models.bundle.Bundle) -> models.patch.Patch:
@@ -144,6 +189,10 @@ def osv_severity_to_cdx(severity: list[dict[str, Any]] | None, textual: str) ->
return None
+def patch_document(doc: yyjson.Document, patch_ops: models.patch.Patch) -> yyjson.Document:
+ return doc.patch(yyjson.Document(patch_to_data(patch_ops)))
+
+
def patch_to_data(patch_ops: models.patch.Patch) -> list[dict[str, Any]]:
return [op.model_dump(by_alias=True, exclude_none=True) for op in patch_ops]
@@ -241,31 +290,10 @@ def _map_severity(severity: str) -> str:
def _record_task(task: str, revision: str, doc: yyjson.Document, patch_ops: models.patch.Patch) -> models.patch.Patch:
properties: list[dict[str, str]] | None = get_pointer(doc, "/properties")
- tools: dict[str, Any] | None = get_pointer(doc, "/metadata/tools")
- services: list[dict[str, Any]] | None = get_pointer(doc, "/metadata/tools/services")
operation = {"name": f"asf:atr:{task}", "value": revision}
if properties is None:
patch_ops.append(models.patch.AddOp(op="add", path="/properties", value=[operation]))
else:
properties.append(operation)
patch_ops.append(models.patch.ReplaceOp(op="replace", path="/properties", value=properties))
- if tools is None:
- tools = {}
- patch_ops.append(models.patch.AddOp(op="add", path="/metadata/tools", value=tools))
- if services is None:
- services = []
- patch_ops.append(models.patch.AddOp(op="add", path="/metadata/tools/services", value=services))
- try:
- tool_index = [s.get("bom-ref", "") for s in services].index("tool:asf:atr")
- except ValueError:
- tool_index = -1
- if tool_index == -1:
- patch_ops.append(
- models.patch.AddOp(op="add", path=f"/metadata/tools/services/{len(services)}", value=_TOOL_METADATA)
- )
- else:
- # If we ever add more metadata to this, such as task-specific properties under the tool, this changes
- patch_ops.append(
- models.patch.ReplaceOp(op="replace", path=f"/metadata/tools/services/{tool_index}", value=_TOOL_METADATA)
- )
- return patch_ops
+ return record_atr_tool(doc, patch_ops)
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 85be0749..443e4eef 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -23,6 +23,7 @@ from typing import Any, Final
import aiofiles
import aiofiles.os
+import yyjson
import atr.archives as archives
import atr.config as config
@@ -450,8 +451,14 @@ async def _generate_cyclonedx_core(artifact_path: safe.StatePath, output_path: s
# Write the SBOM data to the specified output path
try:
+ # Record ASF as the manufacturer and ATR as a tool before we write
+ doc = yyjson.Document(json.dumps(sbom_data))
+ patch_ops: sbom.models.patch.Patch = []
+ sbom.utilities.record_manufacturer(doc, patch_ops)
+ sbom.utilities.record_atr_tool(doc, patch_ops)
+ merged = sbom.utilities.patch_document(doc, patch_ops)
async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
- await f.write(json.dumps(sbom_data, indent=2))
+ await f.write(json.dumps(merged.as_obj, indent=2))
log.info(f"Successfully wrote SBOM to {output_path}")
except Exception as write_err:
log.exception(f"Failed to write SBOM JSON to {output_path}: {write_err}")
diff --git a/typestubs/cyclonedx/model/bom.pyi b/typestubs/cyclonedx/model/bom.pyi
index d543f8c0..dab58ce9 100644
--- a/typestubs/cyclonedx/model/bom.pyi
+++ b/typestubs/cyclonedx/model/bom.pyi
@@ -10,6 +10,7 @@ from py_serializable import _JsonSerializable, _XmlSerializable, _T
class BomMetaData:
component: Optional[Component]
supplier: Optional[OrganizationalEntity]
+ manufacturer: Optional[OrganizationalEntity]
authors: Any
timestamp: datetime
tools: Any
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org