You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/09/14 18:42:20 UTC

(tooling-trusted-releases) branch sbp updated: Add a check for SBOM parsing failures

This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 2eb03ca4 Add a check for SBOM parsing failures
2eb03ca4 is described below

commit 2eb03ca4ed383109a61f1699e9f50b6110c63cfc
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon Sep 14 19:41:31 2026 +0100

    Add a check for SBOM parsing failures
---
 atr/models/sql.py              |   3 ++
 atr/tasks/__init__.py          |  26 ++++++++++
 atr/tasks/checks/sbom.py       |  18 +++++++
 atr/tasks/task.py              |   1 +
 tests/unit/test_sbom_review.py | 115 +++++++++++++++++++++++++++++++++++++++++
 5 files changed, 163 insertions(+)

diff --git a/atr/models/sql.py b/atr/models/sql.py
index 73d71ba3..b0371961 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -288,6 +288,7 @@ class TaskType(enum.StrEnum):
     SBOM_HEATMAP = "sbom_heatmap"
     SBOM_OSV_SCAN = "sbom_osv_scan"
     SBOM_QS_SCORE = "sbom_qs_score"
+    SBOM_REVIEW = "sbom_review"
     SBOM_TOOL_SCORE = "sbom_tool_score"
     SIGNATURE_CHECK = "signature_check"
     SVN_IMPORT_FILES = "svn_import_files"
@@ -356,6 +357,8 @@ class TaskType(enum.StrEnum):
                 return "SBOM vulnerability scan"
             case TaskType.SBOM_QS_SCORE:
                 return "SBOM QS score"
+            case TaskType.SBOM_REVIEW:
+                return "SBOM review"
             case TaskType.SBOM_TOOL_SCORE:
                 return "SBOM tool score"
             case TaskType.SIGNATURE_CHECK:
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 045057c1..a8bdd691 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -439,6 +439,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
             return sbom.osv_scan
         case sql.TaskType.SBOM_QS_SCORE:
             return sbom.score_qs
+        case sql.TaskType.SBOM_REVIEW:
+            return sbom_check.review
         case sql.TaskType.SBOM_TOOL_SCORE:
             return sbom.score_tool
         case sql.TaskType.SIGNATURE_CHECK:
@@ -834,6 +836,7 @@ async def _draft_file_checks(
     for maybe_task in (
         await _archive_comparison_task(asf_uid, release, revision_number, path),
         await _has_sbom_task(asf_uid, release, revision_number, path),
+        await _sbom_review_task(asf_uid, release, revision_number, path),
         await _cyclonedx_score_task(
             asf_uid, release, revision_number, path, project_key, release_version, previous_version
         ),
@@ -867,6 +870,29 @@ async def _has_sbom_task(
     )
 
 
+async def _sbom_review_task(
+    asf_uid: str, release: sql.Release, revision_number: safe.RevisionNumber, path: safe.RelPath
+) -> sql.Task | None:
+    if not analysis.is_cyclonedx_json(path.as_path().name):
+        return None
+    return await queued(
+        asf_uid,
+        sql.TaskType.SBOM_REVIEW,
+        release,
+        revision_number,
+        str(path),
+        check_cache_key=await checks.resolve_cache_key(
+            sbom_check.review,
+            sbom_check.REVIEW_VERSION,
+            [],
+            release,
+            revision_number,
+            {"path": str(path)},
+            file=str(path),
+        ),
+    )
+
+
 TASK_FUNCTIONS: Final[dict[str, Callable[..., Coroutine[Any, Any, list[sql.Task | None]]]]] = {
     ".asc": asc_checks,
     ".sha256": sha_checks,
diff --git a/atr/tasks/checks/sbom.py b/atr/tasks/checks/sbom.py
index e6f6cc58..9548cdfd 100644
--- a/atr/tasks/checks/sbom.py
+++ b/atr/tasks/checks/sbom.py
@@ -15,6 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import asyncio
 from typing import Any, Final
 
 import aiofiles.os
@@ -22,6 +23,7 @@ import aiofiles.os
 import atr.analysis as analysis
 import atr.log as log
 import atr.models.results as results
+import atr.sbom.streaming as streaming
 import atr.tasks.checks as checks
 import atr.tasks.task as task
 
@@ -29,6 +31,7 @@ import atr.tasks.task as task
 INPUT_POLICY_KEYS: Final[list[str]] = []
 INPUT_EXTRA_ARGS: Final[list[str]] = ["suffixed_file_existence"]
 CHECK_VERSION: Final[str] = "3"
+REVIEW_VERSION: Final = "1"
 
 
 async def check(args: checks.FunctionArguments) -> results.Results | None:
@@ -57,6 +60,21 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
     return None
 
 
+async def review(args: checks.FunctionArguments) -> results.Results | None:
+    recorder = await args.recorder(REVIEW_VERSION)
+    if (path := await recorder.abs_path()) is None:
+        return None
+    try:
+        await asyncio.to_thread(streaming.sbom, path.path)
+    except streaming.MalformedError as error:
+        await recorder.concern(f"SBOM structure cannot be interpreted reliably: {error}", {"problem": str(error)})
+    except streaming.UnsupportedError:
+        return None
+    except (OSError, streaming.LimitError) as error:
+        raise task.CheckRetryableError("SBOM structure could not be checked", {"error": str(error)}) from error
+    return None
+
+
 async def _check_core_logic(recorder: checks.Recorder, artifact_rel_path: str) -> dict[str, Any]:
     """Verify an SBOM exists for the specified artifact."""
 
diff --git a/atr/tasks/task.py b/atr/tasks/task.py
index 092521ed..8618312b 100644
--- a/atr/tasks/task.py
+++ b/atr/tasks/task.py
@@ -46,6 +46,7 @@ CHECK_TASK_TYPES: Final[frozenset[sql.TaskType]] = frozenset(
         sql.TaskType.LICENSE_HEADERS,
         sql.TaskType.PATHS_CHECK,
         sql.TaskType.RAT_CHECK,
+        sql.TaskType.SBOM_REVIEW,
         sql.TaskType.SIGNATURE_CHECK,
         sql.TaskType.TARGZ_STRUCTURE,
         sql.TaskType.ZIPFORMAT_STRUCTURE,
diff --git a/tests/unit/test_sbom_review.py b/tests/unit/test_sbom_review.py
new file mode 100644
index 00000000..c620893f
--- /dev/null
+++ b/tests/unit/test_sbom_review.py
@@ -0,0 +1,115 @@
+# 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.
+
+import datetime
+import unittest.mock as mock
+
+import pytest
+
+import atr.attestable as attestable
+import atr.db as db
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.sbom.streaming as streaming
+import atr.tasks as tasks
+import atr.tasks.checks as checks
+import atr.tasks.checks.sbom as sbom
+import atr.tasks.task as task
+import tests.unit.recorders as recorders
+
+
+@pytest.fixture
+def review_input(tmp_path):
+    path = tmp_path / "sbom.cdx.json"
+    recorder = recorders.RecorderStub(safe.StatePath(path), "atr.tasks.checks.sbom.review", sbom.REVIEW_VERSION)
+    args = checks.FunctionArguments(
+        recorders.get_recorder(recorder),
+        "tester",
+        safe.ProjectKey("test"),
+        safe.VersionKey("1.0"),
+        safe.RevisionNumber("00001"),
+        safe.RelPath(path.name),
+        {},
+    )
+    return path, recorder, args
+
+
+@pytest.mark.parametrize(
+    ("content", "concerns"),
+    [
+        (b'{"bomFormat":"CycloneDX","specVersion":"1.6","components":[]}', 0),
+        (b'{"spdxVersion":"SPDX-2.3"}', 0),
+        (b"{", 1),
+        (b'{"bomFormat":"CycloneDX","specVersion":"1.6","x":1,"x":2}', 1),
+    ],
+)
+async def test_review_emits_only_structural_concerns(review_input, content, concerns) -> None:
+    path, recorder, args = review_input
+    path.write_bytes(content)
+    assert await sbom.review(args) is None
+    assert len(recorder.messages) == concerns
+    for status, message, data in recorder.messages:
+        assert status == sql.CheckResultStatus.CONCERN
+        assert data and data["problem"] in message
+
+
+@pytest.mark.parametrize("error", [OSError("unreadable"), streaming.LimitError("limit")])
+async def test_review_limits_and_io_are_operational(review_input, monkeypatch, error) -> None:
+    _path, recorder, args = review_input
+    monkeypatch.setattr(streaming, "sbom", mock.Mock(side_effect=error))
+    with pytest.raises(task.CheckRetryableError, match="SBOM structure could not be checked"):
+        await sbom.review(args)
+    assert recorder.messages == []
+
+
+async def test_review_task_cache_and_sidecar(review_input, monkeypatch) -> None:
+    _path, _recorder, args = review_input
+    release = sql.Release(
+        key="test-1.0",
+        project_key="test",
+        version="1.0",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        created=datetime.datetime.now(datetime.UTC),
+    )
+    release.project = sql.Project(key="test", name="Apache Test")
+    session = mock.AsyncMock()
+    session.__aenter__.return_value = session
+    session.release_file_hash_at.return_value = "blake3:content"
+    monkeypatch.setattr(db, "session", mock.Mock(return_value=session))
+    monkeypatch.setattr(attestable, "load", mock.AsyncMock(return_value=None))
+    write_checks = mock.AsyncMock()
+    monkeypatch.setattr(attestable, "write_checks_data", write_checks)
+    queued = []
+    for revision, name in [("00001", "sbom.cdx.json"), ("00002", "sbom.cdx.json"), ("00002", "other.cdx.json")]:
+        result = await tasks._sbom_review_task("tester", release, safe.RevisionNumber(revision), safe.RelPath(name))
+        assert result is not None
+        assert result.revision_number == revision
+        queued.append(result)
+    assert queued[0].inputs_hash == queued[1].inputs_hash
+    assert queued[1].inputs_hash != queued[2].inputs_hash
+    assert tasks.resolve(queued[0].task_type) is sbom.review
+    assert queued[0].task_type in task.CHECK_TASK_TYPES
+    write_checks.assert_awaited_with(
+        args.project_key,
+        args.version_key,
+        safe.RevisionNumber("00002"),
+        "other.cdx.json",
+        {checks.function_key(sbom.review): queued[2].inputs_hash},
+    )
+    assert (
+        await tasks._sbom_review_task("tester", release, args.revision_number, safe.RelPath("archive.tar.gz")) is None
+    )


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org