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/08/03 14:05:38 UTC

(tooling-trusted-releases) branch arm updated: #1475 - remove SBOM write actions

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


The following commit(s) were added to refs/heads/arm by this push:
     new 09676775 #1475 - remove SBOM write actions
09676775 is described below

commit 0967677553e17a33ce3d8160c7cb5197acd3b2bb
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Mon Aug 3 15:05:11 2026 +0100

    #1475 - remove SBOM write actions
---
 atr/get/checks.py                                  |  10 +-
 atr/get/draft.py                                   |  27 --
 atr/get/sbom.py                                    | 448 +++------------------
 atr/htm.py                                         |   6 +
 atr/post/sbom.py                                   |  13 +-
 atr/templates/check-selected-path-table.html       |   4 -
 atr/templates/draft-tools.html                     |  16 -
 tests/e2e/sbom/conftest.py                         |   1 +
 tests/e2e/sbom/test_post.py                        |  47 +--
 .../e2e/test_files/apache-test-0.2.tar.gz.cdx.json |  68 ++++
 10 files changed, 162 insertions(+), 478 deletions(-)

diff --git a/atr/get/checks.py b/atr/get/checks.py
index 8a99458d..da8fb0ae 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -377,14 +377,7 @@ def _render_file_row(
 
     sbom_btn = None
     if analysis.is_cyclonedx_json(path.as_path().name):
-        sbom_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=quality_url)["Quality report"]
-
-    components_btn = None
-    if (info is not None) and (path in info.sbom_paths):
-        components_url = util.as_url(
-            sbom.components, project_key=release.project.key, version_key=release.version, file_path=path_str
-        )
-        components_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=components_url)["View SBOM"]
+        sbom_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=quality_url)["View SBOM"]
 
     download_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=download_url)["Download"]
 
@@ -396,7 +389,6 @@ def _render_file_row(
             htpy.div(".d-flex.justify-content-end.align-items-center.gap-2")[
                 report_btn,
                 sbom_btn,
-                components_btn,
                 download_btn,
             ],
         ],
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 0451fe94..62b84e79 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -78,31 +78,6 @@ async def tools(
         submit_classes="btn-outline-secondary",
         empty=True,
     )
-    sbom_form = await form.render(
-        model_cls=form.Empty,
-        action=util.as_url(
-            post.draft.sbomgen,
-            project_key=str(project_key),
-            version_key=str(version_key),
-            file_path=str(file_path),
-        ),
-        submit_label="Generate CycloneDX SBOM (.cdx.json)",
-        submit_classes="btn-outline-secondary",
-        empty=True,
-    )
-    sbom_convert_form = await form.render(
-        model_cls=form.Empty,
-        action=util.as_url(
-            post.draft.sbomconvert,
-            project_key=str(project_key),
-            version_key=str(version_key),
-            file_path=str(file_path),
-        ),
-        submit_label="Convert XML SBOM (.cdx.xml)",
-        submit_classes="btn-outline-secondary",
-        empty=True,
-    )
-
     return await template.render(
         "draft-tools.html",
         asf_id=session.uid,
@@ -113,6 +88,4 @@ async def tools(
         release=release,
         format_file_size=util.format_file_size,
         sha512_form=sha512_form,
-        sbom_form=sbom_form,
-        sbom_convert_form=sbom_convert_form,
     )
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 1d4ffb32..adf83522 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -20,16 +20,13 @@ from __future__ import annotations
 import asyncio
 import json
 import re
-from typing import TYPE_CHECKING, Any, Final, Literal
+from typing import Any, Final, Literal
 
-import asfquart.base as base
 import cmarkgfm
 import markupsafe
 
-import atr.analysis as analysis
 import atr.blueprints.get as get
 import atr.db as db
-import atr.form as form
 import atr.get.compose as compose
 import atr.get.vote as vote
 import atr.htm as htm
@@ -38,205 +35,95 @@ import atr.models.results as results
 import atr.models.safe as safe
 import atr.models.sql as sql
 import atr.paths as paths
-import atr.post as post
 import atr.render as render
 import atr.sbom as sbom
 import atr.shared as shared
 import atr.template as template
-import atr.user as user
 import atr.util as util
 import atr.web as web
 
-if TYPE_CHECKING:
-    from collections.abc import Sequence
-
 
 @get.typed
-async def components(
+async def quality(
     session: web.Committer,
-    _sbom_components: Literal["sbom/components"],
+    _sbom_quality: Literal["sbom/quality"],
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     file_path: safe.RelPath,
 ) -> str:
     """
-    URL: /sbom/components/<project_key>/<version_key>/<file_path>
+    URL: /sbom/quality/<project_key>/<version_key>/<file_path>
     """
     release = await shared.sbom.release_in_phase(session, project_key, version_key, with_committee=True)
-
     base_path = paths.release_directory(release)
-    sbom_rel_path = await shared.sbom.sbom_for_artifact(base_path, file_path)
-    if sbom_rel_path is None:
-        raise base.ASFQuartException("This file has no CycloneDX JSON SBOM", errorcode=404)
 
     block = htm.Block()
     _phase_nav(block, release)
     block.h1["SBOM report"]
-    block.p[
-        "The contents of ",
-        htm.code[str(sbom_rel_path)],
-        ", the SBOM for ",
-        htm.code[str(file_path)],
-        ".",
-    ]
+    block.p["A report on the SBOM ", htm.code[str(file_path)], "."]
 
-    task, _augment_tasks, osv_tasks = await _fetch_tasks(str(sbom_rel_path), project_key, release, version_key)
+    task = await _score_task(str(file_path), project_key, release, version_key)
+    task_result = _score_result(task)
 
-    breakdown = await _breakdown(base_path, sbom_rel_path)
-    block.h2["Components"]
+    block.h2["Content"]
+    breakdown = await _breakdown(base_path, file_path)
+    block.h3["Components"]
     if breakdown is None:
         block.p["This SBOM could not be read, so its components cannot be shown."]
     else:
-        quality_url = util.as_url(
-            quality, project_key=str(project_key), version_key=str(version_key), file_path=str(sbom_rel_path)
-        )
-        block.p[htm.a(href=quality_url)["View the quality report for this SBOM"], "."]
         _components_section(block, breakdown, _license_categories(task))
-
     _license_section(block, task)
-    await _vulnerability_scan_section(
-        block,
-        task,
-        osv_tasks,
-        util.as_url(
-            post.sbom.components, project_key=str(project_key), version_key=str(version_key), file_path=str(file_path)
-        ),
-        can_scan=(release.phase != sql.ReleasePhase.RELEASE_CANDIDATE)
-        and user.is_committee_member(release.committee, session.uid),
-    )
+    _vulnerability_section(block, task, task_result)
 
-    return await template.blank("SBOM report", content=block.collect())
-
-
-@get.typed
-async def quality(
-    session: web.Committer,
-    _sbom_quality: Literal["sbom/quality"],
-    project_key: safe.ProjectKey,
-    version_key: safe.VersionKey,
-    file_path: safe.RelPath,
-) -> str:
-    """
-    URL: /sbom/quality/<project_key>/<version_key>/<file_path>
-    """
-    release = await shared.sbom.release_in_phase(session, project_key, version_key, with_committee=True)
-
-    block = htm.Block()
-
-    is_release_candidate = release.phase == sql.ReleasePhase.RELEASE_CANDIDATE
-    _phase_nav(block, release)
-
-    block.h1["SBOM quality report"]
-
-    validated_path_str = str(file_path)
-
-    task, augment_tasks, _osv_tasks = await _fetch_tasks(validated_path_str, project_key, release, version_key)
-
-    task_status = await _report_task_results(block, task)
-    if task_status:
-        return task_status
+    block.h2["Quality"]
+    # The quality checks all read from the score task, so until it has run there is nothing to show
+    if task_result is None:
+        block.p[_score_unavailable(task)]
+    else:
+        _conformance_section(block, task_result)
+        _outdated_tool_section(block, task_result)
+        _cyclonedx_cli_errors(block, task_result)
 
-    if (task is None) or (not isinstance(task.result, results.SBOMToolScore)):
-        raise base.ASFQuartException("Invalid SBOM score result", errorcode=500)
+    return await template.blank("SBOM report", content=block.collect())
 
-    task_result = task.result
-    _report_header(block, is_release_candidate, release, task_result)
 
-    artifact_path_str = _artifact_path_for_sbom(validated_path_str)
-    if artifact_path_str is not None:
-        components_url = util.as_url(
-            components, project_key=str(project_key), version_key=str(version_key), file_path=artifact_path_str
-        )
-        block.p[htm.a(href=components_url)["View the components in this SBOM"], "."]
-
-    if not is_release_candidate:
-        latest_augment = None
-        last_augmented_bom = None
-        if len(augment_tasks) > 0:
-            latest_augment = augment_tasks[0]
-            augment_results: list[Any] = [t.result for t in augment_tasks]
-            augmented_bom_versions = [
-                r.bom_version for r in augment_results if (r is not None) and (r.bom_version is not None)
-            ]
-            if len(augmented_bom_versions) > 0:
-                last_augmented_bom = max(augmented_bom_versions)
-        await _augment_section(
-            block,
-            release,
-            task_result,
-            latest_augment,
-            last_augmented_bom,
-            can_augment=user.is_committee_member(release.committee, session.uid),
+async def _score_task(
+    file_path: str, project: safe.ProjectKey, release: sql.Release, version: safe.VersionKey
+) -> sql.Task | None:
+    async with db.session() as data:
+        via = sql.validate_instrumented_attribute
+        tasks = (
+            await data.task(
+                project_key=str(project),
+                version_key=str(version),
+                revision_number=release.latest_revision_number,
+                task_type=sql.TaskType.SBOM_TOOL_SCORE,
+                primary_rel_path=file_path,
+            )
+            .order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
+            .all()
         )
+        return tasks[0] if (len(tasks) > 0) else None
 
-    _conformance_section(block, task_result)
-
-    _outdated_tool_section(block, task_result)
-
-    _cyclonedx_cli_errors(block, task_result)
 
-    return await template.blank("SBOM quality report", content=block.collect())
-
-
-def _artifact_path_for_sbom(sbom_path: str) -> str | None:
-    # The reverse of shared.sbom.sbom_for_artifact: recover the artifact the SBOM describes so the
-    # quality report can link across to the components report, which is keyed by the artifact
-    for suffix in analysis.CYCLONEDX_JSON_SUFFIXES:
-        if sbom_path.endswith(suffix):
-            return sbom_path.removesuffix(suffix)
-    return None
-
-
-async def _augment_section(
-    block: htm.Block,
-    release: sql.Release,
-    task_result: results.SBOMToolScore,
-    latest_task: sql.Task | None,
-    last_bom: int | None,
-    can_augment: bool,
-):
-    augments = []
-    if task_result.atr_props is not None:
-        augments = [t.get("value", "") for t in task_result.atr_props if t.get("name", "") == "asf:atr:augment"]
-    if latest_task is not None:
-        result: Any = latest_task.result
-        if (latest_task.status == sql.TaskStatus.ACTIVE) or (latest_task.status == sql.TaskStatus.QUEUED):
-            block.p["This SBOM is currently being augmented by ATR."]
-            return
-        if latest_task.status == sql.TaskStatus.FAILED:
-            block.p[f"ATR attempted to augment this SBOM but failed: {latest_task.error}"]
-            return
-        if (last_bom is not None) and (result.bom_version == last_bom) and (len(augments) != 0):
-            block.p["This SBOM was augmented by ATR at revision ", htm.code[augments[-1]], "."]
-            return
-
-    if len(augments) == 0:
-        if not can_augment:
-            block.p["This SBOM has not been augmented by ATR."]
-            return
-        block.p["We can attempt to augment this SBOM with additional data."]
-        await form.render_block(
-            block,
-            model_cls=shared.sbom.AugmentSBOMForm,
-            submit_label="Augment SBOM",
-            empty=True,
-        )
+def _vulnerability_section(block: htm.Block, task: sql.Task | None, task_result: results.SBOMToolScore | None) -> None:
+    block.h3["Vulnerabilities"]
+    # These are the vulnerabilities the SBOM itself declares, which the score task reads out of the document
+    if task_result is None:
+        block.p[_score_unavailable(task)]
+        return
+    if task_result.vulnerabilities is not None:
+        vulnerabilities = [results.CdxVulnAdapter.validate_python(json.loads(e)) for e in task_result.vulnerabilities]
     else:
-        # These are edge cases as they cover situations where the BOM says it was augmented but we don't have a task
-        # record for it
-        if release.latest_revision_number in augments:
-            block.p["This SBOM was augmented by ATR."]
-        else:
-            block.p["This SBOM was augmented by ATR at revision ", htm.code[augments[-1]], "."]
-            if not can_augment:
-                return
-            block.p["We can perform augmentation again to check for additional new data."]
-            await form.render_block(
-                block,
-                model_cls=shared.sbom.AugmentSBOMForm,
-                submit_label="Re-augment SBOM",
-                empty=True,
-            )
+        vulnerabilities = []
+    previous_vulns = None
+    if task_result.prev_vulnerabilities is not None:
+        prev = [results.CdxVulnAdapter.validate_python(json.loads(e)) for e in task_result.prev_vulnerabilities]
+        previous_osv = [
+            (_cdx_to_osv(v), [a.get("ref", "") for a in v.affects] if (v.affects is not None) else []) for v in prev
+        ]
+        previous_vulns = {v.id: (_extract_vulnerability_severity(v), a) for v, a in previous_osv}
+    _vulnerability_results_from_bom(vulnerabilities, block, [], previous_vulns)
 
 
 async def _breakdown(base_path: safe.StatePath, sbom_rel_path: safe.RelPath) -> sbom.models.components.Breakdown | None:
@@ -322,15 +209,15 @@ def _components_table(
 
 
 def _conformance_section(block: htm.Block, task_result: results.SBOMToolScore) -> None:
-    block.h2["Conformance report"]
+    block.h3["Conformance report"]
     warnings = [sbom.models.conformance.MissingAdapter.validate_python(json.loads(w)) for w in task_result.warnings]
     errors = [sbom.models.conformance.MissingAdapter.validate_python(json.loads(e)) for e in task_result.errors]
     if warnings:
-        block.h3[htm.icon("exclamation-triangle-fill", ".me-2.text-warning"), "Warnings"]
+        block.h4[htm.icon("exclamation-triangle-fill", ".me-2.text-warning"), "Warnings"]
         _missing_table(block, warnings)
 
     if errors:
-        block.h3[htm.icon("x-octagon-fill", ".me-2.text-danger"), "Errors"]
+        block.h4[htm.icon("x-octagon-fill", ".me-2.text-danger"), "Errors"]
         _missing_table(block, errors)
 
     if not (warnings or errors):
@@ -352,7 +239,7 @@ def _cve_reference(references: list[dict[str, Any]]) -> tuple[str, str] | None:
 
 
 def _cyclonedx_cli_errors(block: htm.Block, task_result: results.SBOMToolScore):
-    block.h2["CycloneDX CLI validation errors"]
+    block.h3["CycloneDX CLI validation errors"]
     if task_result.cli_errors:
         block.pre["\n".join(task_result.cli_errors)]
     else:
@@ -380,48 +267,6 @@ def _extract_vulnerability_severity(vuln: results.VulnerabilityDetails) -> str:
     return "Unknown"
 
 
-async def _fetch_tasks(
-    file_path: str, project: safe.ProjectKey, release: sql.Release, version: safe.VersionKey
-) -> tuple[sql.Task | None, Sequence[sql.Task], Sequence[sql.Task]]:
-    # TODO: Abstract this code and the sbomtool.MissingAdapter validators
-    async with db.session() as data:
-        via = sql.validate_instrumented_attribute
-        tasks = (
-            await data.task(
-                project_key=str(project),
-                version_key=str(version),
-                revision_number=release.latest_revision_number,
-                task_type=sql.TaskType.SBOM_TOOL_SCORE,
-                primary_rel_path=file_path,
-            )
-            .order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
-            .all()
-        )
-        augment_tasks = (
-            await data.task(
-                project_key=str(project),
-                version_key=str(version),
-                task_type=sql.TaskType.SBOM_AUGMENT,
-                primary_rel_path=file_path,
-            )
-            .order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
-            .all()
-        )
-        # Run or running scans for the current revision
-        osv_tasks = (
-            await data.task(
-                project_key=str(project),
-                version_key=str(version),
-                task_type=sql.TaskType.SBOM_OSV_SCAN,
-                primary_rel_path=file_path,
-                revision_number=release.latest_revision_number,
-            )
-            .order_by(sql.sqlmodel.desc(via(sql.Task.added)))
-            .all()
-        )
-        return (tasks[0] if (len(tasks) > 0) else None), augment_tasks, osv_tasks
-
-
 def _license_badge(name: str, categories: dict[str, sbom.models.licenses.Category]) -> htm.Element:
     category = categories.get(name, sbom.models.licenses.Category.A)
     return htm.div(".d-flex.align-items-center")[
@@ -456,7 +301,7 @@ def _license_category_style(category: sbom.models.licenses.Category) -> str:
 
 
 def _license_section(block: htm.Block, task: sql.Task | None) -> None:
-    block.h2["Licenses"]
+    block.h3["Licenses"]
     # The licence issues come from the score, so without one there is nothing to list
     task_result = _score_result(task)
     if task_result is None:
@@ -473,11 +318,11 @@ def _license_section(block: htm.Block, task: sql.Task | None) -> None:
         errors = _load_license_issues(task_result.license_errors)
     # TODO: Rework the rendering of these since category in the table is redundant.
     if warnings:
-        block.h3[htm.icon("exclamation-triangle-fill", ".me-2.text-warning"), "Warnings"]
+        block.h4[htm.icon("exclamation-triangle-fill", ".me-2.text-warning"), "Warnings"]
         _license_table(block, warnings, prev_licenses)
 
     if errors:
-        block.h3[htm.icon("x-octagon-fill", ".me-2.text-danger"), "Errors"]
+        block.h4[htm.icon("x-octagon-fill", ".me-2.text-danger"), "Errors"]
         _license_table(block, errors, prev_licenses)
 
     if not (warnings or errors):
@@ -597,7 +442,7 @@ def _missing_tally(items: list[sbom.models.conformance.Missing]) -> list[tuple[s
 
 
 def _outdated_tool_section(block: htm.Block, task_result: results.SBOMToolScore):
-    block.h2["Outdated tools"]
+    block.h3["Outdated tools"]
     if task_result.outdated:
         outdated = []
         if isinstance(task_result.outdated, str):
@@ -662,28 +507,6 @@ def _phase_nav(block: htm.Block, release: sql.Release) -> None:
     )
 
 
-def _report_header(
-    block: htm.Block, is_release_candidate: bool, release: sql.Release, task_result: results.SBOMToolScore
-) -> None:
-    block.p[
-        """This is a report by the ATR SBOM tool, for debugging and
-        informational purposes. Please use it only as an approximate
-        guideline to the quality of your SBOM file."""
-    ]
-    if not is_release_candidate:
-        # TODO: Mark if a subsequent score has failed
-        block.p["This report is for revision ", htm.code[str(task_result.revision_number)], "."]
-    elif release.phase == sql.ReleasePhase.RELEASE_CANDIDATE:
-        block.p[f"This report is for the latest {release.version} release candidate."]
-
-
-async def _report_task_results(block: htm.Block, task: sql.Task | None):
-    if _score_result(task) is not None:
-        return None
-    block.p[_score_unavailable(task)]
-    return await template.blank("SBOM quality report", content=block.collect())
-
-
 def _score_result(task: sql.Task | None) -> results.SBOMToolScore | None:
     if task is None:
         return None
@@ -858,152 +681,3 @@ def _vulnerability_results_from_bom(
     new_str = f" ({total_new!s} new since last release)" if (total_new > 0) else ""
     block.p[f"Vulnerabilities{new_str} found in {len(components)} components:"]
     block.append(new_block)
-
-
-def _vulnerability_results_from_scan(
-    task: sql.Task, block: htm.Block, previous_vulns: dict[str, tuple[str, list[str]]] | None
-) -> None:
-    total_new = 0
-    new_block = htm.Block()
-    task_result = task.result
-    if not isinstance(task_result, results.SBOMOSVScan):
-        block.p["Invalid scan result format."]
-        return
-
-    components = task_result.components
-    ignored = task_result.ignored
-    ignored_count = len(ignored)
-
-    if not components:
-        block.p["No vulnerabilities found."]
-        if ignored_count > 0:
-            component_word = "component was" if (ignored_count == 1) else "components were"
-            block.p[f"{ignored_count} {component_word} ignored due to missing PURL or version information:"]
-            block.p[f"{','.join(ignored)}"]
-        return
-
-    for component in components:
-        new = _vulnerability_component_details_osv(new_block, component, previous_vulns)
-        total_new = total_new + new
-
-    new_str = f" ({total_new!s} new since last release)" if (total_new > 0) else ""
-    block.p[f"Scan found vulnerabilities{new_str} in {len(components)} components:"]
-
-    if ignored_count > 0:
-        component_word = "component was" if (ignored_count == 1) else "components were"
-        block.p[f"{ignored_count} {component_word} ignored due to missing PURL or version information:"]
-        block.p[f"{','.join(ignored)}"]
-    block.append(new_block)
-
-
-async def _vulnerability_scan_button(block: htm.Block, action: str) -> None:
-    block.p["You can perform a new vulnerability scan."]
-
-    await form.render_block(
-        block,
-        model_cls=shared.sbom.ScanSBOMForm,
-        action=action,
-        submit_label="Scan file",
-        empty=True,
-    )
-
-
-def _vulnerability_scan_find_completed_task(
-    osv_tasks: Sequence[sql.Task], revision_number: safe.RevisionNumber
-) -> sql.Task | None:
-    """Find the most recent completed OSV scan task for the given revision."""
-    for task in osv_tasks:
-        if (task.status == sql.TaskStatus.COMPLETED) and (task.result is not None):
-            task_result = task.result
-            if isinstance(task_result, results.SBOMOSVScan) and (task_result.revision_number == revision_number):
-                return task
-    return None
-
-
-def _vulnerability_scan_find_in_progress_task(
-    osv_tasks: Sequence[sql.Task], revision_number: safe.RevisionNumber
-) -> sql.Task | None:
-    """Find the most recent in-progress OSV scan task for the given revision."""
-    for task in osv_tasks:
-        if task.revision_number == str(revision_number):
-            if task.status in (sql.TaskStatus.QUEUED, sql.TaskStatus.ACTIVE, sql.TaskStatus.FAILED):
-                return task
-    return None
-
-
-def _vulnerability_scan_results(
-    block: htm.Block,
-    vulns: list[results.CdxVulnerabilityDetail],
-    scans: list[str],
-    task: sql.Task | None,
-    prev: list[results.CdxVulnerabilityDetail] | None,
-) -> None:
-    previous_vulns = None
-    if prev is not None:
-        previous_osv = [
-            (_cdx_to_osv(v), [a.get("ref", "") for a in v.affects] if (v.affects is not None) else []) for v in prev
-        ]
-        previous_vulns = {v.id: (_extract_vulnerability_severity(v), a) for v, a in previous_osv}
-    if task is not None:
-        _vulnerability_results_from_scan(task, block, previous_vulns)
-    else:
-        _vulnerability_results_from_bom(vulns, block, scans, previous_vulns)
-
-
-async def _vulnerability_scan_section(
-    block: htm.Block,
-    task: sql.Task | None,
-    osv_tasks: Sequence[sql.Task],
-    scan_action: str,
-    can_scan: bool,
-) -> None:
-    """Display the vulnerability scan section based on task status."""
-    block.h2["Vulnerabilities"]
-
-    # The vulnerabilities come from the score, so without one there is nothing to list. Scanning is
-    # still worth offering, since it is what produces a score in the first place
-    task_result = _score_result(task)
-    if task_result is None:
-        block.p[_score_unavailable(task)]
-        if can_scan:
-            await _vulnerability_scan_button(block, scan_action)
-        return
-
-    completed_task = _vulnerability_scan_find_completed_task(osv_tasks, task_result.revision_number)
-
-    in_progress_task = _vulnerability_scan_find_in_progress_task(osv_tasks, task_result.revision_number)
-
-    scans = []
-    if task_result.vulnerabilities is not None:
-        vulnerabilities = [results.CdxVulnAdapter.validate_python(json.loads(e)) for e in task_result.vulnerabilities]
-    else:
-        vulnerabilities = []
-    if task_result.prev_vulnerabilities is not None:
-        prev_vulnerabilities = [
-            results.CdxVulnAdapter.validate_python(json.loads(e)) for e in task_result.prev_vulnerabilities
-        ]
-    else:
-        prev_vulnerabilities = None
-    if task_result.atr_props is not None:
-        scans = [t.get("value", "") for t in task_result.atr_props if t.get("name", "") == "asf:atr:osv-scan"]
-    _vulnerability_scan_results(block, vulnerabilities, scans, completed_task, prev_vulnerabilities)
-
-    # A scan in progress is worth reporting to anyone reading, but only a committee member can act
-    if in_progress_task is not None:
-        await _vulnerability_scan_status(block, in_progress_task, scan_action, can_scan)
-    elif can_scan:
-        await _vulnerability_scan_button(block, scan_action)
-
-
-async def _vulnerability_scan_status(block: htm.Block, task: sql.Task, scan_action: str, can_scan: bool) -> None:
-    status_text = task.status.value.replace("_", " ").capitalize()
-    block.p[f"Vulnerability scan is currently {status_text.lower()}."]
-    block.p["Task ID: ", htm.code[str(task.id)]]
-    if (task.status == sql.TaskStatus.FAILED) and (task.error is not None):
-        block.p[
-            "Task reported an error: ",
-            htm.code[task.error],
-            ". Additional details are unavailable from ATR.",
-        ]
-        if can_scan:
-            await _vulnerability_scan_button(block, scan_action)
diff --git a/atr/htm.py b/atr/htm.py
index 5445af9c..dea8eed6 100644
--- a/atr/htm.py
+++ b/atr/htm.py
@@ -45,6 +45,7 @@ form = htpy.form
 h1 = htpy.h1
 h2 = htpy.h2
 h3 = htpy.h3
+h4 = htpy.h4
 html = htpy.html
 i = htpy.i
 li = htpy.li
@@ -211,6 +212,11 @@ class Block:
         self.__check_parent("h3", {"body", "div"})
         return BlockElementCallable(self, h3)
 
+    @property
+    def h4(self) -> BlockElementCallable:
+        self.__check_parent("h4", {"body", "div"})
+        return BlockElementCallable(self, h4)
+
     @property
     def li(self) -> BlockElementCallable:
         self.__check_parent("li", {"ul", "ol"})
diff --git a/atr/post/sbom.py b/atr/post/sbom.py
index ce29ad27..874707d6 100644
--- a/atr/post/sbom.py
+++ b/atr/post/sbom.py
@@ -52,7 +52,7 @@ async def components(
     sbom_path = await shared.sbom.sbom_for_artifact(paths.release_directory(release), file_path)
     if sbom_path is None:
         raise base.ASFQuartException("This file has no CycloneDX JSON SBOM", errorcode=404)
-    return await _scan(session, release, project_key, version_key, sbom_path, file_path)
+    return await _scan(session, release, project_key, version_key, sbom_path)
 
 
 @post.typed
@@ -116,9 +116,8 @@ async def _scan(
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     rel_path: safe.RelPath,
-    artifact_path: safe.RelPath,
 ) -> web.WerkzeugResponse:
-    """Scan a CycloneDX SBOM file for vulnerabilities using OSV, returning to the artifact's page."""
+    """Scan a CycloneDX SBOM file for vulnerabilities using OSV, returning to the SBOM report."""
     if not analysis.is_cyclonedx_json(rel_path.as_path().name):
         raise base.ASFQuartException("OSV scanning is only supported for CycloneDX JSON files", errorcode=400)
 
@@ -137,16 +136,16 @@ async def _scan(
         log.exception("Error starting OSV scan:")
         await quart.flash(f"Error starting OSV scan: {e!s}", "error")
         return await session.redirect(
-            get.sbom.components,
+            get.sbom.quality,
             project_key=str(project_key),
             version_key=str(version_key),
-            file_path=str(artifact_path),
+            file_path=str(rel_path),
         )
 
     return await session.redirect(
-        get.sbom.components,
+        get.sbom.quality,
         success=f"OSV vulnerability scan queued for {rel_path!s} (task ID: {util.unwrap(sbom_task.id)})",
         project_key=str(project_key),
         version_key=str(version_key),
-        file_path=str(artifact_path),
+        file_path=str(rel_path),
     )
diff --git a/atr/templates/check-selected-path-table.html b/atr/templates/check-selected-path-table.html
index b2bd65dc..f61a9131 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -117,10 +117,6 @@
               {% endif %}
               {% if is_cyclonedx_json_fn(path.as_path().name) %}
                 <a href="{{ as_url(get.sbom.quality, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
-                   class="btn btn-sm btn-outline-secondary">Quality report</a>
-              {% endif %}
-              {% if info and (path in info.sbom_paths) %}
-                <a href="{{ as_url(get.sbom.components, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
                    class="btn btn-sm btn-outline-secondary">View SBOM</a>
               {% endif %}
               {% if has_blockers %}
diff --git a/atr/templates/draft-tools.html b/atr/templates/draft-tools.html
index 0b2155a9..40972fd9 100644
--- a/atr/templates/draft-tools.html
+++ b/atr/templates/draft-tools.html
@@ -32,20 +32,4 @@
     {{ sha512_form|safe }}
   </div>
 
-  {% if (file_path.endswith(".tar.gz") or file_path.endswith(".zip") or file_path.endswith(".jar") or file_path.endswith(".tgz")) and is_viewing_as_admin_fn(current_user.uid) %}
-    <h3>Generate SBOM</h3>
-    <div class="alert-info">
-      <p>NOTE: This functionality is in early release.</p>
-    </div>
-    <p>Generate a CycloneDX Software Bill of Materials (SBOM) file for this artifact.</p>
-    {{ sbom_form|safe }}
-  {% endif %}
-  {% if is_cyclonedx_xml_fn(file_path) and is_viewing_as_admin_fn(current_user.uid) %}
-    <h3>Convert SBOM</h3>
-    <div class="alert-info">
-      <p>NOTE: This functionality is in early release.</p>
-    </div>
-    <p>Convert this XML CycloneDX Software Bill of Materials (SBOM) into a JSON equivalent.</p>
-    {{ sbom_convert_form|safe }}
-  {% endif %}
 {% endblock content %}
diff --git a/tests/e2e/sbom/conftest.py b/tests/e2e/sbom/conftest.py
index 9f860c2a..207a2e20 100644
--- a/tests/e2e/sbom/conftest.py
+++ b/tests/e2e/sbom/conftest.py
@@ -44,6 +44,7 @@ def page_release_with_file(page: Page) -> Generator[Page]:
             f"{sbom_helpers.CURRENT_DIR}/../test_files/{sbom_helpers.FILE_NAME}",
             f"{sbom_helpers.CURRENT_DIR}/../test_files/{sbom_helpers.FILE_NAME}.sha512",
             f"{sbom_helpers.CURRENT_DIR}/../test_files/{sbom_helpers.FILE_NAME}.asc",
+            f"{sbom_helpers.CURRENT_DIR}/../test_files/{sbom_helpers.FILE_NAME}.cdx.json",
         ]
     )
     page.get_by_role("button", name="Add files").click()
diff --git a/tests/e2e/sbom/test_post.py b/tests/e2e/sbom/test_post.py
index 213a9e1f..5d7d0d43 100644
--- a/tests/e2e/sbom/test_post.py
+++ b/tests/e2e/sbom/test_post.py
@@ -20,43 +20,34 @@ import e2e.sbom.helpers as sbom_helpers
 from playwright.sync_api import Page, expect
 
 
-def test_sbom_generate(page_release_with_file: Page) -> None:
-    # Make sure that the test file exists
-    file_cell = page_release_with_file.locator("#files-table-container").get_by_role(
-        "cell", name=sbom_helpers.FILE_NAME, exact=True
-    )
-    expect(file_cell).to_be_visible()
-
-    # Generate an SBOM for the file
-    helpers.visit(
-        page_release_with_file,
-        f"/draft/tools/{sbom_helpers.PROJECT_KEY}/{sbom_helpers.VERSION_KEY}/{sbom_helpers.FILE_NAME}",
-    )
-    generate_button = page_release_with_file.get_by_role("button", name="SBOM")
-    generate_button.click()
-
-    # Check that the generated SBOM exists now
-    helpers.visit(page_release_with_file, f"/compose/{sbom_helpers.PROJECT_KEY}/{sbom_helpers.VERSION_KEY}")
-    page_release_with_file.wait_for_selector("#ongoing-tasks-banner", state="hidden")
-    page_release_with_file.reload()
-
+def test_sbom_report(page_release_with_file: Page) -> None:
+    # The uploaded SBOM shows up as a file of its own in the release
     sbom_cell = page_release_with_file.locator("#files-table-container").get_by_role(
         "cell", name=f"{sbom_helpers.FILE_NAME}.cdx.json"
     )
     expect(sbom_cell).to_be_visible()
 
-    # The file the SBOM describes offers a view of that SBOM's components
+    # The SBOM's own row is the single way in to its report
     view_sbom = page_release_with_file.locator("#files-table-container").get_by_role("link", name="View SBOM")
     expect(view_sbom).to_be_visible()
     view_sbom.click()
 
+    # One report, split into a Content section and a Quality section
     expect(page_release_with_file.get_by_role("heading", name="SBOM report")).to_be_visible()
-    expect(page_release_with_file.get_by_text(f"{sbom_helpers.FILE_NAME}.cdx.json")).to_be_visible()
-
-    # What the SBOM says about the artifact lives here, and scanning returns to this same page
-    expect(page_release_with_file.get_by_role("heading", name="Components")).to_be_visible()
+    expect(page_release_with_file.get_by_role("heading", name="Content", exact=True)).to_be_visible()
+    expect(page_release_with_file.get_by_role("heading", name="Components", exact=True)).to_be_visible()
     expect(page_release_with_file.get_by_role("heading", name="Licenses")).to_be_visible()
     expect(page_release_with_file.get_by_role("heading", name="Vulnerabilities")).to_be_visible()
-    page_release_with_file.get_by_role("button", name="Scan file").click()
-    page_release_with_file.wait_for_url("**/sbom/components/**")
-    expect(page_release_with_file.get_by_role("heading", name="SBOM report")).to_be_visible()
+    expect(page_release_with_file.get_by_role("heading", name="Quality", exact=True)).to_be_visible()
+    expect(page_release_with_file.get_by_role("heading", name="Conformance report")).to_be_visible()
+
+    # Nothing on the report can change the SBOM in place
+    expect(page_release_with_file.get_by_role("button", name="Scan file")).to_have_count(0)
+    expect(page_release_with_file.get_by_role("button", name="Augment SBOM")).to_have_count(0)
+
+    # The draft tools no longer offer to generate an SBOM either
+    helpers.visit(
+        page_release_with_file,
+        f"/draft/tools/{sbom_helpers.PROJECT_KEY}/{sbom_helpers.VERSION_KEY}/{sbom_helpers.FILE_NAME}",
+    )
+    expect(page_release_with_file.get_by_role("button", name="SBOM")).to_have_count(0)
diff --git a/tests/e2e/test_files/apache-test-0.2.tar.gz.cdx.json b/tests/e2e/test_files/apache-test-0.2.tar.gz.cdx.json
new file mode 100644
index 00000000..c68bd671
--- /dev/null
+++ b/tests/e2e/test_files/apache-test-0.2.tar.gz.cdx.json
@@ -0,0 +1,68 @@
+{
+  "bomFormat": "CycloneDX",
+  "specVersion": "1.6",
+  "version": 1,
+  "metadata": {
+    "timestamp": "2026-01-01T00:00:00Z",
+    "authors": [
+      {
+        "name": "ATR end to end tests"
+      }
+    ],
+    "supplier": {
+      "name": "The Apache Software Foundation",
+      "url": [
+        "https://www.apache.org/"
+      ]
+    },
+    "component": {
+      "type": "application",
+      "bom-ref": "apache-test",
+      "name": "apache-test",
+      "version": "0.2",
+      "supplier": {
+        "name": "The Apache Software Foundation",
+        "url": [
+          "https://www.apache.org/"
+        ]
+      },
+      "purl": "pkg:generic/apache-test@0.2"
+    }
+  },
+  "components": [
+    {
+      "type": "library",
+      "bom-ref": "example-lib",
+      "name": "example-lib",
+      "version": "1.0.0",
+      "supplier": {
+        "name": "Example Org"
+      },
+      "purl": "pkg:maven/org.example/example-lib@1.0.0",
+      "licenses": [
+        {
+          "license": {
+            "id": "Apache-2.0"
+          }
+        }
+      ]
+    },
+    {
+      "type": "library",
+      "bom-ref": "another-lib",
+      "name": "another-lib",
+      "version": "2.1.0",
+      "supplier": {
+        "name": "Example Org"
+      },
+      "purl": "pkg:maven/org.example/another-lib@2.1.0",
+      "licenses": [
+        {
+          "license": {
+            "id": "MIT"
+          }
+        }
+      ]
+    }
+  ]
+}


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