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