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