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/07/17 16:21:26 UTC
(tooling-trusted-releases) branch sbp updated: Add an API endpoint to generate augmented SBOMs
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 3faf688e Add an API endpoint to generate augmented SBOMs
3faf688e is described below
commit 3faf688e33140d4d0110b971116dc3064be40e33
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Jul 17 17:20:54 2026 +0100
Add an API endpoint to generate augmented SBOMs
---
atr/api/__init__.py | 63 +++++++++++
atr/models/api.py | 20 ++++
atr/models/attestable.py | 1 +
atr/models/results.py | 15 +++
atr/models/sql.py | 6 ++
atr/storage/writers/sbom.py | 50 +++++++++
atr/tasks/__init__.py | 2 +
atr/tasks/sbom.py | 64 +++++++++++
tests/unit/test_api_sbom_generate.py | 126 ++++++++++++++++++++++
tests/unit/test_archived_project_writer_guards.py | 20 ++++
10 files changed, 367 insertions(+)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 5f51ce24..6dc07520 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1557,6 +1557,44 @@ async def releases_list(
).model_dump(mode="json"), 200
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.SbomGenerateResults, 202),
+ rate_limit=(10, datetime.timedelta(hours=1)),
+)
+async def sbom_generate(
+ _sbom_generate: Literal["sbom/generate"],
+ data: models.api.SbomGenerateArgs,
+) -> DictResponse:
+ asf_uid = _jwt_asf_uid()
+ if not str(data.relpath).endswith((".tar.gz", ".tgz", ".zip", ".jar")):
+ raise exceptions.BadRequest("SBOM generation requires .tar.gz, .tgz, .zip or .jar files")
+ async with db.session() as db_data:
+ release_key = sql.release_key(data.project, data.version)
+ release = await db_data.release(key=str(release_key), _committee=True).get()
+ if (release is None) or user.embargo_hides_release(release, asf_uid, is_member=False):
+ raise exceptions.NotFound(f"Release {release_key} not found")
+ if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise exceptions.Conflict(f"Release {release_key} is not a candidate draft")
+
+ try:
+ async with storage.write(asf_uid) as write:
+ wacp = await write.as_project_committee_participant(data.project)
+ task = await wacp.sbom.generate_cyclonedx_revision(
+ data.project,
+ data.version,
+ release.safe_latest_revision_number,
+ data.relpath,
+ )
+ except storage.AccessError as e:
+ raise _http_exception_from_storage_access_error(e) from e
+
+ return models.api.SbomGenerateResults(
+ endpoint="/sbom/generate",
+ task=task,
+ ).model_dump(mode="json"), 202
+
+
@api.typed(
auth_scheme=api_auth.Auth.BEARER,
response=(models.api.SignatureProvenanceResults, 200),
@@ -1707,6 +1745,31 @@ async def ssh_keys_list(
).model_dump(mode="json"), 200
+@api.typed(
+ auth_scheme=api_auth.Auth.BEARER,
+ response=(models.api.TaskGetResults, 200),
+)
+async def task_get(
+ _task_get: Literal["task/get"],
+ task_id: int,
+) -> DictResponse:
+ asf_uid = _jwt_asf_uid()
+ async with db.session() as data:
+ task = await data.task(id=task_id).get()
+ release = None
+ if (task is not None) and (task.project_key is not None) and (task.version_key is not None):
+ release_key = sql.release_key(task.project_key, task.version_key)
+ release = await data.release(key=str(release_key), _committee=True).get()
+ if (task is None) or (task.asf_uid != asf_uid):
+ raise exceptions.NotFound(f"Task {task_id} not found")
+ if (release is not None) and user.embargo_hides_release(release, asf_uid, is_member=False):
+ raise exceptions.NotFound(f"Task {task_id} not found")
+ return models.api.TaskGetResults(
+ endpoint="/task/get",
+ task=task,
+ ).model_dump(mode="json"), 200
+
+
@api.typed(auth_scheme=api_auth.Auth.BODY_OIDC)
async def update_distribution_task_status(
_distribute_task_status: Literal["distribute/task/status"],
diff --git a/atr/models/api.py b/atr/models/api.py
index 78a4122f..a2630129 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -786,6 +786,17 @@ class ReleasesListResults(schema.Strict):
count: int
+class SbomGenerateArgs(schema.Strict):
+ project: safe.ProjectKey = schema.example("example")
+ version: safe.VersionKey = schema.example("0.0.1")
+ relpath: safe.RelPath = schema.example("example-0.0.1-bin.tar.gz")
+
+
+class SbomGenerateResults(schema.Strict):
+ endpoint: Literal["/sbom/generate"] = schema.alias("endpoint")
+ task: sql.Task
+
+
class SignatureProvenanceArgs(schema.Strict):
signature_file_name: str = schema.example("example-0.0.1-bin.tar.gz.asc")
signature_asc_text: str = schema.example("-----BEGIN PGP SIGNATURE-----\n\n...\n-----END PGP SIGNATURE-----\n")
@@ -837,6 +848,11 @@ class SshKeysListResults(schema.Strict):
count: int = schema.example(10)
+class TaskGetResults(schema.Strict):
+ endpoint: Literal["/task/get"] = schema.alias("endpoint")
+ task: sql.Task
+
+
@dataclasses.dataclass
class TasksListQuery:
limit: int = 20
@@ -993,10 +1009,12 @@ type Results = Annotated[
| ReleaseRevisionsResults
| ReleaseUploadResults
| ReleasesListResults
+ | SbomGenerateResults
| SignatureProvenanceResults
| SshKeyAddResults
| SshKeyDeleteResults
| SshKeysListResults
+ | TaskGetResults
| TasksListResults
| UserInfoResults
| UsersListResults
@@ -1054,10 +1072,12 @@ validate_release_paths = validator(ReleasePathsResults)
validate_release_revisions = validator(ReleaseRevisionsResults)
validate_release_upload = validator(ReleaseUploadResults)
validate_releases_list = validator(ReleasesListResults)
+validate_sbom_generate = validator(SbomGenerateResults)
validate_signature_provenance = validator(SignatureProvenanceResults)
validate_ssh_key_add = validator(SshKeyAddResults)
validate_ssh_key_delete = validator(SshKeyDeleteResults)
validate_ssh_keys_list = validator(SshKeysListResults)
+validate_task_get = validator(TaskGetResults)
validate_tasks_list = validator(TasksListResults)
validate_user_info = validator(UserInfoResults)
validate_users_list = validator(UsersListResults)
diff --git a/atr/models/attestable.py b/atr/models/attestable.py
index 8ea29432..2ec7a44c 100644
--- a/atr/models/attestable.py
+++ b/atr/models/attestable.py
@@ -54,6 +54,7 @@ class HashEntryV2(schema.Strict):
class GeneratorV2(enum.Enum):
+ SBOM_FROM_ARTIFACT = "SBOM_from_artifact"
SHA512_FROM_CONTENT = "SHA512_from_content"
SHA512_FROM_SIGNATURE = "SHA512_from_signature"
diff --git a/atr/models/results.py b/atr/models/results.py
index 831ee178..9082bc1b 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -177,6 +177,20 @@ class SBOMConvert(schema.Strict):
)
+class SBOMGenerate(schema.Strict):
+ kind: Literal["sbom_generate"] = schema.Field(alias="kind")
+ path: str = schema.description("The path to the generated SBOM file, relative to the release directory")
+ bom_version: int | None = schema.Field(
+ default=None,
+ strict=False,
+ description="BOM Version produced by the augmentation, if any augmentations were applied",
+ )
+ revision_number: str | None = schema.Field(
+ default=None,
+ description="The number of the new revision containing the generated SBOM file",
+ )
+
+
class SBOMQsScore(schema.Strict):
kind: Literal["sbom_qs_score"] = schema.Field(alias="kind")
project_key: safe.ProjectKey = schema.description("Project name")
@@ -293,6 +307,7 @@ Results = Annotated[
| MetadataUpdate
| SBOMAugment
| SBOMConvert
+ | SBOMGenerate
| SBOMGenerateCycloneDX
| SBOMOSVScan
| SBOMQsScore
diff --git a/atr/models/sql.py b/atr/models/sql.py
index 756e8c56..12c3b51a 100644
--- a/atr/models/sql.py
+++ b/atr/models/sql.py
@@ -248,6 +248,7 @@ class TaskType(enum.StrEnum):
RAT_CHECK = "rat_check"
SBOM_AUGMENT = "sbom_augment"
SBOM_CONVERT = "sbom_convert"
+ SBOM_GENERATE = "sbom_generate"
SBOM_GENERATE_CYCLONEDX = "sbom_generate_cyclonedx"
SBOM_OSV_SCAN = "sbom_osv_scan"
SBOM_QS_SCORE = "sbom_qs_score"
@@ -303,6 +304,8 @@ class TaskType(enum.StrEnum):
return "SBOM augmentation"
case TaskType.SBOM_CONVERT:
return "SBOM conversion"
+ case TaskType.SBOM_GENERATE:
+ return "SBOM generation and augmentation"
case TaskType.SBOM_GENERATE_CYCLONEDX:
return "SBOM generation"
case TaskType.SBOM_OSV_SCAN:
@@ -763,6 +766,9 @@ class Task(sqlmodel.SQLModel, table=True):
if isinstance(self.completed, str):
self.completed = datetime.datetime.fromisoformat(self.completed.rstrip("Z"))
+ if isinstance(self.result, dict):
+ self.result = results.ResultsAdapter.validate_python(self.result)
+
@property
def safe_primary_rel_path(self) -> safe.RelPath | None:
"""Get the typesafe validated relative path for the task, if set."""
diff --git a/atr/storage/writers/sbom.py b/atr/storage/writers/sbom.py
index 8e984348..c92f68bd 100644
--- a/atr/storage/writers/sbom.py
+++ b/atr/storage/writers/sbom.py
@@ -176,6 +176,56 @@ class CommitteeParticipant(FoundationCommitter):
await self.__data.refresh(sbom_task)
return sbom_task
+ async def generate_cyclonedx_revision(
+ self,
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision_number: safe.RevisionNumber,
+ rel_path: safe.RelPath,
+ ) -> sql.Task:
+ await self.__data.begin_immediate()
+ release_key = sql.release_key(str(project_key), str(version_key))
+ release = await self.__data.release(key=str(release_key)).demand(
+ storage.AccessError(f"Release '{project_key!s} {version_key!s}' not found.", status=404)
+ )
+ storage.ensure_project_active(release.project)
+ self.__write.ensure_release_writable(release)
+ if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+ raise storage.AccessError(
+ f"Release '{project_key!s} {version_key!s}' is not a candidate draft.", status=409
+ )
+ ongoing = await self.__data.task(
+ status_in=[sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE],
+ task_type=sql.TaskType.SBOM_GENERATE,
+ asf_uid=util.unwrap(self.__asf_uid),
+ project_key=str(project_key),
+ version_key=str(version_key),
+ primary_rel_path=str(rel_path),
+ ).all()
+ if ongoing:
+ return ongoing[0]
+ sbom_task = sql.Task(
+ task_type=sql.TaskType.SBOM_GENERATE,
+ task_args=args.FileArgs(
+ project_key=project_key,
+ version_key=version_key,
+ revision_number=revision_number,
+ file_path=rel_path,
+ asf_uid=util.unwrap(self.__asf_uid),
+ ).model_dump(),
+ asf_uid=util.unwrap(self.__asf_uid),
+ added=datetime.datetime.now(datetime.UTC),
+ status=sql.TaskStatus.QUEUED,
+ project_key=str(project_key),
+ version_key=str(version_key),
+ revision_number=str(revision_number),
+ primary_rel_path=str(rel_path),
+ )
+ self.__data.add(sbom_task)
+ await self.__data.commit()
+ await self.__data.refresh(sbom_task)
+ return sbom_task
+
async def osv_scan_cyclonedx(
self,
project_key: safe.ProjectKey,
diff --git a/atr/tasks/__init__.py b/atr/tasks/__init__.py
index 5f35a1d8..56b1a1a1 100644
--- a/atr/tasks/__init__.py
+++ b/atr/tasks/__init__.py
@@ -381,6 +381,8 @@ def resolve(task_type: sql.TaskType) -> Callable[..., Awaitable[results.Results
return sbom.augment
case sql.TaskType.SBOM_CONVERT:
return sbom.convert_cyclonedx
+ case sql.TaskType.SBOM_GENERATE:
+ return sbom.generate
case sql.TaskType.SBOM_GENERATE_CYCLONEDX:
return sbom.generate_cyclonedx
case sql.TaskType.SBOM_OSV_SCAN:
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 3fb8e712..43133c0c 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -28,8 +28,10 @@ import orjson
import atr.analysis as analysis
import atr.archives as archives
import atr.config as config
+import atr.hashes as hashes
import atr.log as log
import atr.models.args as args
+import atr.models.attestable as attestable
import atr.models.results as results
import atr.models.safe as safe
import atr.models.sql as sql
@@ -141,6 +143,68 @@ async def convert_cyclonedx(args: args.ConvertCycloneDX) -> results.Results | No
raise
+@checks.with_model(args.FileArgs)
+async def generate(args: args.FileArgs) -> results.Results | None:
+ revision_str = str(args.revision_number)
+ path_str = str(args.file_path)
+
+ base_dir = paths.get_unfinished_dir_for(args.project_key, args.version_key, args.revision_number)
+ if not await aiofiles.os.path.isdir(base_dir):
+ raise SBOMGenerationError("Revision directory does not exist", {"base_dir": str(base_dir)})
+ if not await aiofiles.os.path.isfile(base_dir / path_str):
+ raise SBOMGenerationError("Artifact file does not exist", {"file_path": path_str})
+ sbom_rel_path = path_str + ".cdx.json"
+ bom_version: int | None = None
+ description = "SBOM generation through the API"
+ async with storage.write(args.asf_uid) as write:
+ wacp = await write.as_project_committee_participant(args.project_key)
+
+ async def modify(
+ path: safe.StatePath, _old_rev: sql.Revision | None
+ ) -> dict[safe.RelPath, attestable.ProvenanceV2] | None:
+ nonlocal bom_version
+ artifact_path = path / path_str
+ output_path = path / sbom_rel_path
+ if await aiofiles.os.path.exists(output_path):
+ raise SBOMGenerationError("SBOM file already exists", {"file_path": sbom_rel_path})
+ source_content_hash = await hashes.compute_file_hash(artifact_path)
+ await _generate_cyclonedx_core(artifact_path, output_path)
+ bundle = sbom.utilities.path_to_bundle(output_path.path)
+ if not bundle:
+ raise SBOMGenerationError("Could not load bundle")
+ patch_ops = await sbom.utilities.bundle_to_ntia_patch(bundle)
+ if patch_ops:
+ bom_version, merged = sbom.utilities.apply_patch("augment", revision_str, bundle, patch_ops)
+ await aiofiles.os.remove(output_path)
+ async with aiofiles.open(output_path, "w", encoding="utf-8") as f:
+ await f.write(orjson.dumps(merged).decode())
+ provenance = attestable.ProvenanceV2(
+ generator=attestable.GeneratorV2.SBOM_FROM_ARTIFACT,
+ metadata={
+ "initiated_by": args.asf_uid,
+ "source_content_hashes": {path_str: source_content_hash},
+ "source_paths": [path_str],
+ },
+ )
+ return {safe.RelPath(sbom_rel_path): provenance}
+
+ result = await wacp.revision.create_revision_with_quarantine(
+ args.project_key,
+ args.version_key,
+ args.asf_uid or "unknown",
+ allowed_phases=frozenset({sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT}),
+ description=description,
+ modify=modify,
+ )
+
+ return results.SBOMGenerate(
+ kind="sbom_generate",
+ path=sbom_rel_path,
+ bom_version=bom_version,
+ revision_number=(result.number if isinstance(result, sql.Revision) else None),
+ )
+
+
@checks.with_model(args.GenerateCycloneDX)
async def generate_cyclonedx(args: args.GenerateCycloneDX) -> results.Results | None:
"""Generate a CycloneDX SBOM for the given artifact and write it to the output path."""
diff --git a/tests/unit/test_api_sbom_generate.py b/tests/unit/test_api_sbom_generate.py
new file mode 100644
index 00000000..f65c2e1b
--- /dev/null
+++ b/tests/unit/test_api_sbom_generate.py
@@ -0,0 +1,126 @@
+# 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 types
+import unittest.mock as mock
+
+import pytest
+
+import atr.models as models
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage as storage
+import atr.storage.writers.sbom
+import atr.tasks as tasks
+import atr.tasks.sbom as sbom
+
+
+def test_sbom_generate_results_round_trip() -> None:
+ task = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="test")
+ api_results = models.api.SbomGenerateResults(endpoint="/sbom/generate", task=task)
+
+ parsed = models.api.validate_sbom_generate(api_results.model_dump(mode="json"))
+
+ assert parsed.task.task_type == sql.TaskType.SBOM_GENERATE
+
+
+@pytest.mark.asyncio
+async def test_sbom_generate_revision_blocks_non_draft() -> None:
+ data = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
+ release_query = mock.MagicMock()
+ release_query.demand = mock.AsyncMock(return_value=_draft_release(sql.ReleasePhase.RELEASE_CANDIDATE))
+ data.release = mock.MagicMock(return_value=release_query)
+ writer = _writer_with(data)
+
+ with pytest.raises(storage.AccessError, match="candidate draft"):
+ await writer.generate_cyclonedx_revision(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ safe.RelPath("artifact.tar.gz"),
+ )
+
+
+@pytest.mark.asyncio
+async def test_sbom_generate_revision_reuses_ongoing_task() -> None:
+ existing = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="tester")
+ data = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
+ release_query = mock.MagicMock()
+ release_query.demand = mock.AsyncMock(return_value=_draft_release())
+ data.release = mock.MagicMock(return_value=release_query)
+ task_query = mock.MagicMock()
+ task_query.all = mock.AsyncMock(return_value=[existing])
+ data.task = mock.MagicMock(return_value=task_query)
+ writer = _writer_with(data)
+
+ result = await writer.generate_cyclonedx_revision(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ safe.RelPath("artifact.tar.gz"),
+ )
+
+ assert result is existing
+ data.add.assert_not_called()
+ data.task.assert_called_once_with(
+ status_in=[sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE],
+ task_type=sql.TaskType.SBOM_GENERATE,
+ asf_uid="tester",
+ project_key="project",
+ version_key="1.0.0",
+ primary_rel_path="artifact.tar.gz",
+ )
+
+
+def test_sbom_generate_task_resolves() -> None:
+ assert tasks.resolve(sql.TaskType.SBOM_GENERATE) is sbom.generate
+
+
+def test_task_get_results_round_trip_typed_result() -> None:
+ task = sql.Task(task_type=sql.TaskType.SBOM_GENERATE, task_args={}, asf_uid="test")
+ task.result = results.SBOMGenerate(
+ kind="sbom_generate",
+ path="artifact.tar.gz.cdx.json",
+ bom_version=2,
+ revision_number="00002",
+ )
+ api_results = models.api.TaskGetResults(endpoint="/task/get", task=task)
+
+ parsed = models.api.validate_task_get(api_results.model_dump(mode="json"))
+
+ assert isinstance(parsed.task.result, results.SBOMGenerate)
+ assert parsed.task.result.revision_number == "00002"
+
+
+def _draft_release(phase: sql.ReleasePhase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT) -> types.SimpleNamespace:
+ return types.SimpleNamespace(
+ project=types.SimpleNamespace(is_active=True),
+ is_embargoed=False,
+ phase=phase,
+ )
+
+
+def _writer_with(data: mock.MagicMock) -> atr.storage.writers.sbom.CommitteeParticipant:
+ writer = object.__new__(atr.storage.writers.sbom.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+ writer._CommitteeParticipant__write = mock.MagicMock()
+ return writer
diff --git a/tests/unit/test_archived_project_writer_guards.py b/tests/unit/test_archived_project_writer_guards.py
index 0d6c179c..2b695542 100644
--- a/tests/unit/test_archived_project_writer_guards.py
+++ b/tests/unit/test_archived_project_writer_guards.py
@@ -521,6 +521,26 @@ async def test_sbom_augment_blocks_retired() -> None:
)
+@pytest.mark.asyncio
+async def test_sbom_generate_revision_blocks_retired() -> None:
+ data = mock.MagicMock()
+ data.begin_immediate = mock.AsyncMock()
+ data.release = mock.MagicMock(return_value=_query_returning(_retired_release()))
+ writer = object.__new__(sbom.CommitteeParticipant)
+ writer._CommitteeParticipant__data = data
+ writer._CommitteeParticipant__asf_uid = "tester"
+ writer._CommitteeParticipant__committee_key = "project"
+ writer._CommitteeParticipant__write = mock.MagicMock()
+
+ with pytest.raises(storage.AccessError, match="archived"):
+ await writer.generate_cyclonedx_revision(
+ safe.ProjectKey("project"),
+ safe.VersionKey("1.0.0"),
+ safe.RevisionNumber("00001"),
+ safe.RelPath("artifact.tar.gz"),
+ )
+
+
@pytest.mark.asyncio
async def test_vote_resolve_release_blocks_retired_after_merge() -> None:
data = mock.MagicMock()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org