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/07/16 14:46:54 UTC

(tooling-trusted-releases) 01/01: #1399 - new SBOM report showing content, linked from the artifact itself

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 d9c1873f7b6e399d472705870d8e0b5e21d573f7
Author: Alastair McFarlane <ar...@apache.org>
AuthorDate: Thu Jul 16 15:35:23 2026 +0100

    #1399 - new SBOM report showing content, linked from the artifact itself
---
 atr/analysis.py                                |  21 ++
 atr/get/checks.py                              |   9 +
 atr/get/sbom.py                                | 265 +++++++++++++++++++------
 atr/post/sbom.py                               |  50 +++--
 atr/sbom/__init__.py                           |  17 +-
 atr/sbom/components.py                         |  64 ++++++
 atr/sbom/licenses.py                           |  20 +-
 atr/sbom/models/__init__.py                    |   4 +-
 atr/sbom/models/{__init__.py => components.py} |  27 ++-
 atr/sbom/osv.py                                |  13 +-
 atr/shared/sbom.py                             |  36 +++-
 atr/storage/datatypes.py                       |   2 +
 atr/storage/readers/releases.py                |  11 +
 atr/storage/writers/announce.py                |   3 +-
 atr/svn/catalog.py                             |  11 +-
 atr/tasks/checks/__init__.py                   |   3 +-
 atr/tasks/checks/sbom.py                       |   2 +-
 atr/templates/check-selected-path-table.html   |   4 +
 tests/e2e/sbom/test_post.py                    |  16 ++
 atr/shared/sbom.py => tests/unit/sboms.py      |  30 +--
 tests/unit/test_sbom_candidates.py             |  49 +++++
 tests/unit/test_sbom_components.py             |  52 +++++
 tests/unit/test_sbom_licenses.py               |  51 +++++
 23 files changed, 637 insertions(+), 123 deletions(-)

diff --git a/atr/analysis.py b/atr/analysis.py
index ec05c9e2..464afb21 100755
--- a/atr/analysis.py
+++ b/atr/analysis.py
@@ -211,6 +211,16 @@ def architecture_pattern() -> str:
     return "(" + "|".join(architectures) + ")(?=[_.-])"
 
 
+def artifact_stem(name: str) -> str | None:
+    # The name with its artifact extension taken off, e.g. apache-example-1.0 from
+    # apache-example-1.0.tar.gz, or None where the name carries no artifact extension. Wider than
+    # util.archive_format_stem, which knows only the archive formats
+    search = re.search(extension_pattern(), name)
+    if (not search) or (not search.group("artifact")):
+        return None
+    return name[: search.start()]
+
+
 def candidate_match(segment: str) -> re.Match[str] | None:
     return _CANDIDATE_WHOLE.match(segment) or _CANDIDATE_PARTIAL.search(segment)
 
@@ -470,6 +480,17 @@ def print_data(analysis: Analysis) -> None:
     sys.stdout.flush()
 
 
+def sbom_candidates(name: str, suffixes: tuple[str, ...]) -> list[str]:
+    # An SBOM either takes the whole file name and adds a suffix, e.g.
+    # apache-example-1.0.tar.gz.cdx.json, or stands in place of the artifact extension in the style
+    # of a Java classifier, e.g. apache-example-1.0-cyclonedx.json. Whole name first, as it names
+    # only ever one file, whereas a stem is shared by every format of the same artifact
+    candidates = [name + suffix for suffix in suffixes]
+    if (stem := artifact_stem(name)) is not None:
+        candidates.extend(stem + suffix for suffix in suffixes)
+    return candidates
+
+
 def substitutions_format(substitutions: dict[str, list[str]]) -> str:
     subs = []
     for key, values in substitutions.items():
diff --git a/atr/get/checks.py b/atr/get/checks.py
index a7b33d3a..2c43b83d 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -386,6 +386,14 @@ 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=sbom_url)["SBOM 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"]
+
     download_btn = htpy.a(".btn.btn-sm.btn-outline-secondary", href=download_url)["Download"]
 
     tbody.tr[
@@ -396,6 +404,7 @@ 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/sbom.py b/atr/get/sbom.py
index 2f81919f..5bfa99a6 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -17,6 +17,7 @@
 
 from __future__ import annotations
 
+import asyncio
 import json
 from typing import TYPE_CHECKING, Any, Literal
 
@@ -30,13 +31,17 @@ import atr.form as form
 import atr.get.compose as compose
 import atr.get.vote as vote
 import atr.htm as htm
+import atr.log as log
 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
 
@@ -45,53 +50,81 @@ if TYPE_CHECKING:
 
 
 @get.typed
-async def report(
+async def components(
     session: web.Committer,
-    _sbom_report: Literal["sbom/report"],
+    _sbom_components: Literal["sbom/components"],
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     file_path: safe.RelPath,
 ) -> str:
     """
-    URL: /sbom/report/<project_key>/<version_key>/<file_path>
+    URL: /sbom/components/<project_key>/<version_key>/<file_path>
     """
-    # If the draft is not found, we try to get the release candidate
-    try:
-        release = await session.release(project_key, version_key, with_committee=True)
-    except base.ASFQuartException:
-        release = await session.release(
-            project_key, version_key, phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=True
-        )
+    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 contents"]
+    block.p[
+        "The contents of ",
+        htm.code[str(sbom_rel_path)],
+        ", the SBOM for ",
+        htm.code[str(file_path)],
+        ".",
+    ]
 
-    is_release_candidate = False
-    back_url = ""
-    back_anchor = ""
-    phase: Literal["COMPOSE", "VOTE"] = "COMPOSE"
-    match release.phase:
-        case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-            back_url = util.as_url(compose.selected, project_key=release.project.key, version_key=release.version)
-            back_anchor = f"Compose {release.project.short_display_name} {release.version}"
-            phase = "COMPOSE"
-        case sql.ReleasePhase.RELEASE_CANDIDATE:
-            is_release_candidate = True
-            back_url = util.as_url(vote.selected, project_key=release.project.key, version_key=release.version)
-            back_anchor = f"Vote on {release.project.short_display_name} {release.version}"
-            phase = "VOTE"
+    breakdown = await _breakdown(base_path, sbom_rel_path)
+    block.h2["Components"]
+    if breakdown is None:
+        block.p["This SBOM could not be read, so its components cannot be shown."]
+    else:
+        _components_section(block, breakdown)
 
-    render.html_nav(
+    task, _augment_tasks, osv_tasks = await _fetch_tasks(str(sbom_rel_path), project_key, release, version_key)
+
+    _license_section(block, task)
+    await _vulnerability_scan_section(
         block,
-        back_url=back_url,
-        back_anchor=back_anchor,
-        phase=phase,
+        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),
     )
 
+    return await template.blank("SBOM contents", content=block.collect())
+
+
+@get.typed
+async def report(
+    session: web.Committer,
+    _sbom_report: Literal["sbom/report"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    file_path: safe.RelPath,
+) -> str:
+    """
+    URL: /sbom/report/<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 report"]
 
     validated_path_str = str(file_path)
 
-    task, augment_tasks, osv_tasks = await _fetch_tasks(validated_path_str, project_key, release, version_key)
+    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:
@@ -114,14 +147,16 @@ async def report(
             ]
             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)
+        await _augment_section(
+            block,
+            release,
+            task_result,
+            latest_augment,
+            last_augmented_bom,
+            can_augment=user.is_committee_member(release.committee, session.uid),
+        )
 
     _conformance_section(block, task_result)
-    _license_section(block, task_result)
-
-    await _vulnerability_scan_section(
-        block, str(project_key), str(version_key), str(file_path), task_result, osv_tasks, is_release_candidate
-    )
 
     _outdated_tool_section(block, task_result)
 
@@ -136,6 +171,7 @@ async def _augment_section(
     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:
@@ -153,6 +189,9 @@ async def _augment_section(
             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,
@@ -167,6 +206,8 @@ async def _augment_section(
             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,
@@ -176,6 +217,18 @@ async def _augment_section(
             )
 
 
+async def _breakdown(base_path: safe.StatePath, sbom_rel_path: safe.RelPath) -> sbom.models.components.Breakdown | None:
+    abs_path = (base_path / sbom_rel_path).path
+    try:
+        bundle = await asyncio.to_thread(sbom.utilities.path_to_bundle, abs_path)
+    except Exception:
+        # A malformed SBOM is the author's to fix, so say so on the page rather than erroring out.
+        # The parser gives no single error to catch, and anything it throws means the same thing here
+        log.exception(f"Could not read SBOM at {sbom_rel_path}")
+        return None
+    return sbom.components.breakdown(bundle.bom)
+
+
 def _cdx_to_osv(cdx: results.CdxVulnerabilityDetail) -> results.VulnerabilityDetails:
     score = []
     severity = ""
@@ -195,6 +248,50 @@ def _cdx_to_osv(cdx: results.CdxVulnerabilityDetail) -> results.VulnerabilityDet
     )
 
 
+def _component_label(item: sbom.models.components.Item) -> str:
+    return item.name if (item.version is None) else f"{item.name} {item.version}"
+
+
+def _components_section(block: htm.Block, breakdown: sbom.models.components.Breakdown) -> None:
+    if breakdown.subject is not None:
+        block.p["This SBOM describes ", htm.strong[_component_label(breakdown.subject)], "."]
+
+    if breakdown.total == 0:
+        block.p["This SBOM does not declare any components."]
+        return
+
+    component_word = "component" if (breakdown.total == 1) else "components"
+    type_word = "type" if (len(breakdown.groups) == 1) else "types"
+    block.p[f"{breakdown.total} {component_word} of {len(breakdown.groups)} {type_word}:"]
+
+    for group in breakdown.groups:
+        block.append(
+            htm.details(".mb-3.rounded")[
+                htm.summary[
+                    htm.span(".badge.bg-secondary.me-2.font-monospace")[str(len(group.items))],
+                    htm.strong[group.component_type.capitalize()],
+                ],
+                _components_table(group.items),
+            ]
+        )
+
+
+def _components_table(items: list[sbom.models.components.Item]) -> htm.Element:
+    rows = [
+        htm.tr[
+            htm.td[item.name],
+            htm.td[item.version or "-"],
+            htm.td[", ".join(item.licenses) if item.licenses else "-"],
+            htm.td[htm.code[item.purl] if item.purl else "-"],
+        ]
+        for item in items
+    ]
+    return htm.table(".table.table-sm.table-bordered.table-striped")[
+        htm.thead[htm.tr[htm.th["Name"], htm.th["Version"], htm.th["Licenses"], htm.th["PURL"]]],
+        htm.tbody[*rows],
+    ]
+
+
 def _conformance_section(block: htm.Block, task_result: results.SBOMToolScore) -> None:
     block.h2["Conformance report"]
     warnings = [sbom.models.conformance.MissingAdapter.validate_python(json.loads(w)) for w in task_result.warnings]
@@ -282,8 +379,13 @@ async def _fetch_tasks(
         return (tasks[0] if (len(tasks) > 0) else None), augment_tasks, osv_tasks
 
 
-def _license_section(block: htm.Block, task_result: results.SBOMToolScore) -> None:
+def _license_section(block: htm.Block, task: sql.Task | None) -> None:
     block.h2["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:
+        block.p[_score_unavailable(task)]
+        return
     warnings = []
     errors = []
     prev_licenses = None
@@ -453,6 +555,28 @@ def _outdated_tool_section(block: htm.Block, task_result: results.SBOMToolScore)
         block.p["No outdated tools found."]
 
 
+def _phase_nav(block: htm.Block, release: sql.Release) -> None:
+    back_url = ""
+    back_anchor = ""
+    phase: Literal["COMPOSE", "VOTE"] = "COMPOSE"
+    match release.phase:
+        case sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
+            back_url = util.as_url(compose.selected, project_key=release.project.key, version_key=release.version)
+            back_anchor = f"Compose {release.project.short_display_name} {release.version}"
+            phase = "COMPOSE"
+        case sql.ReleasePhase.RELEASE_CANDIDATE:
+            back_url = util.as_url(vote.selected, project_key=release.project.key, version_key=release.version)
+            back_anchor = f"Vote on {release.project.short_display_name} {release.version}"
+            phase = "VOTE"
+
+    render.html_nav(
+        block,
+        back_url=back_url,
+        back_anchor=back_anchor,
+        phase=phase,
+    )
+
+
 def _report_header(
     block: htm.Block, is_release_candidate: bool, release: sql.Release, task_result: results.SBOMToolScore
 ) -> None:
@@ -469,19 +593,27 @@ def _report_header(
 
 
 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 report", content=block.collect())
+
+
+def _score_result(task: sql.Task | None) -> results.SBOMToolScore | None:
     if task is None:
-        block.p["No SBOM score found."]
-        return await template.blank("SBOM report", content=block.collect())
+        return None
+    return task.result if isinstance(task.result, results.SBOMToolScore) else None
 
-    task_status = task.status
-    task_error = task.error
-    if (task_status == sql.TaskStatus.QUEUED) or (task_status == sql.TaskStatus.ACTIVE):
-        block.p["SBOM score is being computed."]
-        return await template.blank("SBOM report", content=block.collect())
 
-    if task_status == sql.TaskStatus.FAILED:
-        block.p[f"SBOM score task failed: {task_error}"]
-        return await template.blank("SBOM report", content=block.collect())
+def _score_unavailable(task: sql.Task | None) -> str:
+    # Why there is no score to read. Says something only where _score_result found nothing
+    if task is None:
+        return "No SBOM score found."
+    if (task.status == sql.TaskStatus.QUEUED) or (task.status == sql.TaskStatus.ACTIVE):
+        return "SBOM score is being computed."
+    if task.status == sql.TaskStatus.FAILED:
+        return f"SBOM score task failed: {task.error}"
+    return "No SBOM score found."
 
 
 def _severity_to_style(severity: str) -> str:
@@ -660,12 +792,13 @@ def _vulnerability_results_from_scan(
     block.append(new_block)
 
 
-async def _vulnerability_scan_button(block: htm.Block) -> None:
+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,
     )
@@ -715,20 +848,27 @@ def _vulnerability_scan_results(
 
 async def _vulnerability_scan_section(
     block: htm.Block,
-    project: str,
-    version: str,
-    file_path: str,
-    task_result: results.SBOMToolScore,
+    task: sql.Task | None,
     osv_tasks: Sequence[sql.Task],
-    is_release_candidate: bool,
+    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)
 
-    block.h2["Vulnerabilities"]
-
     scans = []
     if task_result.vulnerabilities is not None:
         vulnerabilities = [results.CdxVulnAdapter.validate_python(json.loads(e)) for e in task_result.vulnerabilities]
@@ -744,16 +884,14 @@ async def _vulnerability_scan_section(
         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)
 
-    if not is_release_candidate:
-        if in_progress_task is not None:
-            await _vulnerability_scan_status(block, in_progress_task, project, version, file_path)
-        else:
-            await _vulnerability_scan_button(block)
+    # 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, project: str, version: str, file_path: str
-) -> None:
+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)]]
@@ -763,4 +901,5 @@ async def _vulnerability_scan_status(
             htm.code[task.error],
             ". Additional details are unavailable from ATR.",
         ]
-        await _vulnerability_scan_button(block)
+        if can_scan:
+            await _vulnerability_scan_button(block, scan_action)
diff --git a/atr/post/sbom.py b/atr/post/sbom.py
index 5725d2f8..374752a9 100644
--- a/atr/post/sbom.py
+++ b/atr/post/sbom.py
@@ -27,12 +27,34 @@ import atr.blueprints.post as post
 import atr.get as get
 import atr.log as log
 import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.paths as paths
 import atr.shared as shared
 import atr.storage as storage
 import atr.util as util
 import atr.web as web
 
 
+@post.typed
+async def components(
+    session: web.Committer,
+    _sbom_components: Literal["sbom/components"],
+    project_key: safe.ProjectKey,
+    version_key: safe.VersionKey,
+    file_path: safe.RelPath,
+    _sbom_form: shared.sbom.ScanSBOMForm,
+) -> web.WerkzeugResponse:
+    """
+    URL: /sbom/components/<project_key>/<version_key>/<path:file_path>
+    """
+    # The page is keyed on the file the SBOM describes, so find the SBOM before scanning it
+    release = await shared.sbom.release_in_phase(session, project_key, version_key)
+    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)
+
+
 @post.typed
 async def report(
     session: web.Committer,
@@ -40,18 +62,12 @@ async def report(
     project_key: safe.ProjectKey,
     version_key: safe.VersionKey,
     file_path: safe.RelPath,
-    sbom_form: shared.sbom.SBOMForm,
+    _sbom_form: shared.sbom.AugmentSBOMForm,
 ) -> web.WerkzeugResponse:
     """
     URL: /sbom/report/<project_key>/<version_key>/<path:file_path>
     """
-
-    match sbom_form:
-        case shared.sbom.AugmentSBOMForm():
-            return await _augment(session, project_key, version_key, file_path)
-
-        case shared.sbom.ScanSBOMForm():
-            return await _scan(session, project_key, version_key, file_path)
+    return await _augment(session, project_key, version_key, file_path)
 
 
 async def _augment(
@@ -95,14 +111,18 @@ async def _augment(
 
 
 async def _scan(
-    session: web.Committer, project_key: safe.ProjectKey, version_key: safe.VersionKey, rel_path: safe.RelPath
+    session: web.Committer,
+    release: sql.Release,
+    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."""
+    """Scan a CycloneDX SBOM file for vulnerabilities using OSV, returning to the artifact's page."""
     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)
 
     try:
-        release = await session.release(project_key, version_key, with_committee=False, with_project=False)
         revision_number = release.safe_latest_revision_number
         log.info(f"Starting OSV scan for {project_key!s} {version_key!s} {revision_number!s} {rel_path!s}")
         async with storage.write_as_project_committee_member(project_key) as wacm:
@@ -117,16 +137,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.report,
+            get.sbom.components,
             project_key=str(project_key),
             version_key=str(version_key),
-            file_path=str(rel_path),
+            file_path=str(artifact_path),
         )
 
     return await session.redirect(
-        get.sbom.report,
+        get.sbom.components,
         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(rel_path),
+        file_path=str(artifact_path),
     )
diff --git a/atr/sbom/__init__.py b/atr/sbom/__init__.py
index 595ce276..b13e62d1 100644
--- a/atr/sbom/__init__.py
+++ b/atr/sbom/__init__.py
@@ -15,10 +15,25 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from . import cli, conformance, constants, cyclonedx, licenses, maven, models, osv, sbomqs, spdx, tool, utilities
+from . import (
+    cli,
+    components,
+    conformance,
+    constants,
+    cyclonedx,
+    licenses,
+    maven,
+    models,
+    osv,
+    sbomqs,
+    spdx,
+    tool,
+    utilities,
+)
 
 __all__ = [
     "cli",
+    "components",
     "conformance",
     "constants",
     "cyclonedx",
diff --git a/atr/sbom/components.py b/atr/sbom/components.py
new file mode 100644
index 00000000..8d5d8210
--- /dev/null
+++ b/atr/sbom/components.py
@@ -0,0 +1,64 @@
+# 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 TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from cyclonedx.model.bom import Bom
+    from cyclonedx.model.component import Component
+
+from . import licenses, models
+
+
+def breakdown(bom_value: Bom) -> models.components.Breakdown:
+    grouped: dict[str, list[models.components.Item]] = {}
+    for component in bom_value.components:
+        grouped.setdefault(_type_name(component), []).append(_item(component))
+
+    groups = [
+        models.components.Group(component_type=name, items=sorted(items, key=_sort_key))
+        for name, items in sorted(grouped.items())
+    ]
+
+    subject = None
+    if bom_value.metadata and bom_value.metadata.component:
+        subject = _item(bom_value.metadata.component)
+    return models.components.Breakdown(subject=subject, groups=groups)
+
+
+def _item(component: Component) -> models.components.Item:
+    return models.components.Item(
+        name=component.name or "unknown",
+        version=component.version,
+        licenses=_licenses(component),
+        purl=str(component.purl) if component.purl else None,
+    )
+
+
+def _licenses(component: Component) -> list[str]:
+    values = {expr for choice in component.licenses if (expr := licenses.expression(choice))}
+    return sorted(values)
+
+
+def _sort_key(item: models.components.Item) -> tuple[str, str]:
+    return item.name.casefold(), item.version or ""
+
+
+def _type_name(component: Component) -> str:
+    return component.type.value
diff --git a/atr/sbom/licenses.py b/atr/sbom/licenses.py
index 7c77d86a..2322a13a 100644
--- a/atr/sbom/licenses.py
+++ b/atr/sbom/licenses.py
@@ -19,7 +19,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 
-from cyclonedx.model.license import DisjunctiveLicense, LicenseExpression
+from cyclonedx.model.license import DisjunctiveLicense, License, LicenseExpression
 
 if TYPE_CHECKING:
     from cyclonedx.model.bom import Bom
@@ -51,13 +51,7 @@ def check(
             continue
 
         for license_choice in component.licenses:
-            license_expr = None
-
-            if isinstance(license_choice, LicenseExpression):
-                license_expr = license_choice.value
-            elif isinstance(license_choice, DisjunctiveLicense):
-                license_expr = license_choice.id
-
+            license_expr = expression(license_choice)
             if not license_expr:
                 continue
 
@@ -123,3 +117,13 @@ def check(
                 )
 
     return good, warnings, errors
+
+
+def expression(license_choice: License) -> str | None:
+    # A licence is either an SPDX expression or a single licence, and a single licence carries a
+    # name of its own where it has no SPDX identifier to give
+    if isinstance(license_choice, LicenseExpression):
+        return license_choice.value
+    if isinstance(license_choice, DisjunctiveLicense):
+        return license_choice.id or license_choice.name
+    return None
diff --git a/atr/sbom/models/__init__.py b/atr/sbom/models/__init__.py
index 5735eb71..11e652c3 100644
--- a/atr/sbom/models/__init__.py
+++ b/atr/sbom/models/__init__.py
@@ -17,6 +17,6 @@
 
 from __future__ import annotations
 
-from . import base, bundle, conformance, licenses, osv, patch, sbomqs, tool
+from . import base, bundle, components, conformance, licenses, osv, patch, sbomqs, tool
 
-__all__ = ["base", "bundle", "conformance", "licenses", "osv", "patch", "sbomqs", "tool"]
+__all__ = ["base", "bundle", "components", "conformance", "licenses", "osv", "patch", "sbomqs", "tool"]
diff --git a/atr/sbom/models/__init__.py b/atr/sbom/models/components.py
similarity index 55%
copy from atr/sbom/models/__init__.py
copy to atr/sbom/models/components.py
index 5735eb71..eae0fe99 100644
--- a/atr/sbom/models/__init__.py
+++ b/atr/sbom/models/components.py
@@ -17,6 +17,29 @@
 
 from __future__ import annotations
 
-from . import base, bundle, conformance, licenses, osv, patch, sbomqs, tool
+import pydantic
 
-__all__ = ["base", "bundle", "conformance", "licenses", "osv", "patch", "sbomqs", "tool"]
+from .base import Strict
+
+
+class Item(Strict):
+    name: str
+    version: str | None = None
+    licenses: list[str] = pydantic.Field(default_factory=list)
+    purl: str | None = None
+
+
+class Group(Strict):
+    component_type: str
+    items: list[Item] = pydantic.Field(default_factory=list)
+
+
+class Breakdown(Strict):
+    # The component the BOM is about, from metadata.component. Absent in a BOM that omits it,
+    # and it is not counted in the groups
+    subject: Item | None = None
+    groups: list[Group] = pydantic.Field(default_factory=list)
+
+    @property
+    def total(self) -> int:
+        return sum(len(group.items) for group in self.groups)
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index b319b1c2..36833f7e 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -129,7 +129,7 @@ def vulns_from_bundle(bundle: models.bundle.Bundle) -> list[models.osv.CdxVulner
 def _assemble_component_vulnerability(
     doc: dict[str, Any], patch_ops: models.patch.Patch, ref: str, vuln: models.osv.VulnerabilityDetails, index: int
 ) -> None:
-    vulnerability = {
+    vulnerability: dict[str, Any] = {
         "bom-ref": f"vuln:{ref}/{vuln.id}",
         "id": vuln.id,
         "source": _get_source(vuln),
@@ -142,17 +142,20 @@ def _assemble_component_vulnerability(
         "ratings": osv_severity_to_cdx(vuln.severity, vuln.database_specific.get("severity", "")),
     }
     if vuln.references is not None:
+        # An advisory reference is only worth recording when it says where the advisory is
         vulnerability["advisories"] = [
-            {"url": r["url"]}
+            {"url": url}
             for r in vuln.references
-            if ((r.get("type", "") == "WEB") and ("advisories" in r.get("url", "")))
-            or (r.get("type", "") == "ADVISORY")
+            if (url := r.get("url", ""))
+            and (((r.get("type", "") == "WEB") and ("advisories" in url)) or (r.get("type", "") == "ADVISORY"))
         ]
     patch_ops.append(
         models.patch.AddOp(
             op="add",
             path=f"/vulnerabilities/{index!s}",
-            value=vulnerability,
+            # OSV omits whatever it has nothing to say about, and CycloneDX wants those keys absent
+            # rather than null. A null lands as a BOM that no longer parses at all
+            value={key: value for key, value in vulnerability.items() if value is not None},
         )
     )
 
diff --git a/atr/shared/sbom.py b/atr/shared/sbom.py
index e1838ddd..7e5b63ee 100644
--- a/atr/shared/sbom.py
+++ b/atr/shared/sbom.py
@@ -15,23 +15,45 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Annotated, Literal
+from typing import Literal
 
+import aiofiles.os
+import asfquart.base as base
+
+import atr.analysis as analysis
 import atr.form as form
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.web as web
 
 type AUGMENT = Literal["augment"]
 type SCAN = Literal["scan"]
 
 
+async def release_in_phase(
+    session: web.Committer, project_key: safe.ProjectKey, version_key: safe.VersionKey, with_committee: bool = False
+) -> sql.Release:
+    # If the draft is not found, we try to get the release candidate
+    try:
+        return await session.release(project_key, version_key, with_committee=with_committee)
+    except base.ASFQuartException:
+        return await session.release(
+            project_key, version_key, phase=sql.ReleasePhase.RELEASE_CANDIDATE, with_committee=with_committee
+        )
+
+
+async def sbom_for_artifact(base_path: safe.StatePath, file_path: safe.RelPath) -> safe.RelPath | None:
+    # Only the JSON suffixes pair here, because the SBOM tooling reads JSON alone
+    for candidate in analysis.sbom_candidates(str(file_path), analysis.CYCLONEDX_JSON_SUFFIXES):
+        rel_path = safe.RelPath(candidate)
+        if await aiofiles.os.path.isfile((base_path / rel_path).path):
+            return rel_path
+    return None
+
+
 class AugmentSBOMForm(form.Empty):
     variant: AUGMENT = form.value(AUGMENT)
 
 
 class ScanSBOMForm(form.Empty):
     variant: SCAN = form.value(SCAN)
-
-
-type SBOMForm = Annotated[
-    AugmentSBOMForm | ScanSBOMForm,
-    form.DISCRIMINATOR,
-]
diff --git a/atr/storage/datatypes.py b/atr/storage/datatypes.py
index d4b198ee..c3dd9904 100644
--- a/atr/storage/datatypes.py
+++ b/atr/storage/datatypes.py
@@ -99,6 +99,8 @@ class PathInfo(schema.Strict):
     release_level_concerns: list[sql.CheckResult] = schema.factory(list)
     release_level_exceptions: list[sql.CheckResult] = schema.factory(list)
     release_level_suggestions: list[sql.CheckResult] = schema.factory(list)
+    # Each file that has a CycloneDX JSON SBOM riding alongside it, mapped to that SBOM
+    sbom_paths: dict[safe.RelPath, safe.RelPath] = schema.factory(dict)
     suggestions: dict[safe.RelPath, list[sql.CheckResult]] = schema.factory(dict)
 
 
diff --git a/atr/storage/readers/releases.py b/atr/storage/readers/releases.py
index b3be32c2..d8c00f36 100644
--- a/atr/storage/readers/releases.py
+++ b/atr/storage/readers/releases.py
@@ -21,6 +21,7 @@ from __future__ import annotations
 import collections
 import dataclasses
 
+import atr.analysis as analysis
 import atr.classify as classify
 import atr.db as db
 import atr.db.interaction as interaction
@@ -74,6 +75,7 @@ class GeneralPublic:
                 info.file_types[path] = await classify.classify(
                     path, base_path=base_path, source_matcher=source_matcher, binary_matcher=binary_matcher
                 )
+        self.__pair_sboms(info, all_paths)
         self.__compute_checker_stats(info, all_paths)
         return info
 
@@ -119,6 +121,15 @@ class GeneralPublic:
                 )
             )
 
+    def __pair_sboms(self, info: datatypes.PathInfo, paths: list[safe.RelPath]) -> None:
+        # Only the JSON suffixes pair here, because the SBOM tooling reads JSON alone
+        path_strs = {str(path) for path in paths}
+        for path in paths:
+            for candidate in analysis.sbom_candidates(str(path), analysis.CYCLONEDX_JSON_SUFFIXES):
+                if candidate in path_strs:
+                    info.sbom_paths[path] = safe.RelPath(candidate)
+                    break
+
     async def __notes_suggestions_concerns(
         self, release: sql.Release, latest_revision_number: safe.RevisionNumber, info: datatypes.PathInfo
     ) -> None:
diff --git a/atr/storage/writers/announce.py b/atr/storage/writers/announce.py
index 8e667f7a..27ccb4b9 100644
--- a/atr/storage/writers/announce.py
+++ b/atr/storage/writers/announce.py
@@ -642,8 +642,7 @@ class ReleaseManager(CommitteeParticipant):
                     checksum_path = candidate
                     break
             sbom_path: str | None = None
-            for suffix in analysis.SBOM_SUFFIXES:
-                candidate = f"{rel}{suffix}"
+            for candidate in analysis.sbom_candidates(rel, analysis.SBOM_SUFFIXES):
                 if candidate in rel_paths:
                     sbom_path = candidate
                     break
diff --git a/atr/svn/catalog.py b/atr/svn/catalog.py
index 9cb7c0ad..fe96ca1a 100644
--- a/atr/svn/catalog.py
+++ b/atr/svn/catalog.py
@@ -131,7 +131,7 @@ def _artifacts(rel_files: _ReleaseFiles) -> list[release.ArtifactInput]:
                 download_path_suffix=dirpath,
                 signature_path=_companion(siblings, name, _SIGNATURE_SUFFIXES),
                 checksum_path=_companion(siblings, name, _CHECKSUM_SUFFIXES),
-                sbom_path=_companion(siblings, name, analysis.SBOM_SUFFIXES),
+                sbom_path=_sbom_companion(siblings, name),
             )
         )
     return artifacts
@@ -271,6 +271,15 @@ def _safe_keys(project_key: str, version: str) -> tuple[safe.ProjectKey, safe.Ve
         return None
 
 
+def _sbom_companion(siblings: set[str], artifact: str) -> str | None:
+    # Unlike a signature or a checksum, an SBOM can stand in place of the artifact extension rather
+    # than sit on the end of the whole name
+    for candidate in analysis.sbom_candidates(artifact, analysis.SBOM_SUFFIXES):
+        if candidate in siblings:
+            return candidate
+    return None
+
+
 def _structural_changes(
     changed: dict,
 ) -> tuple[dict[tuple[str, str | None, str], _ReleaseFiles], set[tuple[str, str | None, str]]]:
diff --git a/atr/tasks/checks/__init__.py b/atr/tasks/checks/__init__.py
index 99f3adef..99b410d8 100644
--- a/atr/tasks/checks/__init__.py
+++ b/atr/tasks/checks/__init__.py
@@ -523,8 +523,7 @@ async def _resolve_suffixed_file_existence(release: sql.Release, rel_path: str |
     if (not rel_path) or (not release.latest_revision_number):
         return []
     entries = []
-    for suffix in analysis.SBOM_SUFFIXES:
-        candidate = rel_path + suffix
+    for candidate in analysis.sbom_candidates(rel_path, analysis.SBOM_SUFFIXES):
         abs_path = file_paths.revision_path_for_file(
             release.safe_project_key, release.safe_version_key, release.safe_latest_revision_number, candidate
         )
diff --git a/atr/tasks/checks/sbom.py b/atr/tasks/checks/sbom.py
index 277394ab..c1d9ddf7 100644
--- a/atr/tasks/checks/sbom.py
+++ b/atr/tasks/checks/sbom.py
@@ -58,7 +58,7 @@ async def check(args: checks.FunctionArguments) -> results.Results | None:
 async def _check_core_logic(artifact_path: str) -> dict[str, Any]:
     """Verify an SBOM exists for the specified artifact."""
 
-    sbom_expected_paths = [artifact_path + suffix for suffix in analysis.SBOM_SUFFIXES]
+    sbom_expected_paths = analysis.sbom_candidates(artifact_path, analysis.SBOM_SUFFIXES)
     log.info(f"Attempting to find one of: '{','.join(sbom_expected_paths)}'")
 
     return await _check_core_logic_find_sboms(
diff --git a/atr/templates/check-selected-path-table.html b/atr/templates/check-selected-path-table.html
index e0af8548..acd39ea1 100644
--- a/atr/templates/check-selected-path-table.html
+++ b/atr/templates/check-selected-path-table.html
@@ -120,6 +120,10 @@
                 <a href="{{ as_url(get.sbom.report, project_key=project_key|string, version_key=version_key|string, file_path=path) }}"
                    class="btn btn-sm btn-outline-secondary">SBOM 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 %}
                 <a href="{{ as_url(get.report.selected_path, project_key=project_key|string, version_key=version_key|string, rel_path=path) }}"
                    class="btn btn-sm atr-btn-outline-blocker">Show details</a>
diff --git a/tests/e2e/sbom/test_post.py b/tests/e2e/sbom/test_post.py
index ebceedfe..abd5a2c1 100644
--- a/tests/e2e/sbom/test_post.py
+++ b/tests/e2e/sbom/test_post.py
@@ -44,3 +44,19 @@ def test_sbom_generate(page_release_with_file: Page) -> None:
         "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
+    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()
+
+    expect(page_release_with_file.get_by_role("heading", name="SBOM contents")).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 itself 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="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 contents")).to_be_visible()
diff --git a/atr/shared/sbom.py b/tests/unit/sboms.py
similarity index 56%
copy from atr/shared/sbom.py
copy to tests/unit/sboms.py
index e1838ddd..ac7f459d 100644
--- a/atr/shared/sbom.py
+++ b/tests/unit/sboms.py
@@ -15,23 +15,25 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from typing import Annotated, Literal
+from typing import Any
 
-import atr.form as form
+import cyclonedx.model.bom as cdx_bom
 
-type AUGMENT = Literal["augment"]
-type SCAN = Literal["scan"]
+_BASE: dict[str, Any] = {
+    "bomFormat": "CycloneDX",
+    "specVersion": "1.6",
+    "version": 1,
+}
 
 
-class AugmentSBOMForm(form.Empty):
-    variant: AUGMENT = form.value(AUGMENT)
+def build(payload: dict[str, Any]) -> cdx_bom.Bom:
+    """Build a CycloneDX BOM from the given document fields."""
+    built = cdx_bom.Bom.from_json(data=_BASE | payload)
+    if built is None:
+        raise ValueError("Could not build the BOM under test")
+    return built
 
 
-class ScanSBOMForm(form.Empty):
-    variant: SCAN = form.value(SCAN)
-
-
-type SBOMForm = Annotated[
-    AugmentSBOMForm | ScanSBOMForm,
-    form.DISCRIMINATOR,
-]
+def with_components(*components: dict[str, Any]) -> cdx_bom.Bom:
+    """Build a CycloneDX BOM declaring the given components."""
+    return build({"components": list(components)})
diff --git a/tests/unit/test_sbom_candidates.py b/tests/unit/test_sbom_candidates.py
new file mode 100644
index 00000000..4bfbe3d2
--- /dev/null
+++ b/tests/unit/test_sbom_candidates.py
@@ -0,0 +1,49 @@
+# 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 atr.analysis as analysis
+
+
+def test_artifact_stem() -> None:
+    assert analysis.artifact_stem("apache-example-1.0.tar.gz") == "apache-example-1.0"
+    assert analysis.artifact_stem("apache-example-1.0.jar") == "apache-example-1.0"
+    assert analysis.artifact_stem("apache-example-1.0.pom") == "apache-example-1.0"
+    # Nothing without an artifact extension has a stem, which keeps an SBOM from standing as the
+    # stem of another SBOM
+    assert analysis.artifact_stem("KEYS") is None
+    assert analysis.artifact_stem("apache-example-1.0.tar.gz.cdx.json") is None
+    assert analysis.artifact_stem("apache-example-1.0-cyclonedx.json") is None
+
+
+def test_sbom_candidates() -> None:
+    # Callers take the first candidate they find, so the whole name comes before the stem, which
+    # every format of one artifact shares
+    assert analysis.sbom_candidates("apache-example-1.0.jar", analysis.CYCLONEDX_JSON_SUFFIXES) == [
+        "apache-example-1.0.jar.cdx.json",
+        "apache-example-1.0.jar-cyclonedx.json",
+        "apache-example-1.0.cdx.json",
+        "apache-example-1.0-cyclonedx.json",
+    ]
+    # A name with no artifact extension has only itself to offer
+    assert analysis.sbom_candidates("KEYS", analysis.CYCLONEDX_JSON_SUFFIXES) == [
+        "KEYS.cdx.json",
+        "KEYS-cyclonedx.json",
+    ]
+    # The XML suffixes reach the stem too, which is what the cataloguer and announce pair on
+    assert "apache-example-1.0-cyclonedx.xml" in analysis.sbom_candidates(
+        "apache-example-1.0.jar", analysis.SBOM_SUFFIXES
+    )
diff --git a/tests/unit/test_sbom_components.py b/tests/unit/test_sbom_components.py
new file mode 100644
index 00000000..f4c11d4a
--- /dev/null
+++ b/tests/unit/test_sbom_components.py
@@ -0,0 +1,52 @@
+# 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 atr.sbom.components as components
+import tests.unit.sboms as sboms
+
+
+def test_breakdown() -> None:
+    breakdown = components.breakdown(
+        sboms.build(
+            {
+                "metadata": {"component": {"type": "application", "name": "subject", "version": "9.9"}},
+                "components": [
+                    {"type": "library", "name": "beta", "version": "2.0"},
+                    {"type": "application", "name": "gamma", "version": "3.0"},
+                    {
+                        "type": "library",
+                        "name": "alpha",
+                        "version": "1.0",
+                        "licenses": [{"license": {"id": "Apache-2.0"}}],
+                    },
+                ],
+            }
+        )
+    )
+
+    assert breakdown.subject is not None
+    assert (breakdown.subject.name, breakdown.subject.version) == ("subject", "9.9")
+    # The subject is not one of the components it describes
+    assert breakdown.total == 3
+    assert [(group.component_type, [item.name for item in group.items]) for group in breakdown.groups] == [
+        ("application", ["gamma"]),
+        ("library", ["alpha", "beta"]),
+    ]
+    assert breakdown.groups[1].items[0].licenses == ["Apache-2.0"]
+
+    # A BOM need not say what it is about
+    assert components.breakdown(sboms.with_components({"type": "library", "name": "alpha"})).subject is None
diff --git a/tests/unit/test_sbom_licenses.py b/tests/unit/test_sbom_licenses.py
new file mode 100644
index 00000000..490d8d2e
--- /dev/null
+++ b/tests/unit/test_sbom_licenses.py
@@ -0,0 +1,51 @@
+# 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 cyclonedx.model.license as cdx_license
+
+import atr.sbom.licenses as licenses
+import atr.sbom.models as models
+import tests.unit.sboms as sboms
+
+
+def test_expression() -> None:
+    assert licenses.expression(cdx_license.LicenseExpression(value="Apache-2.0 OR MIT")) == "Apache-2.0 OR MIT"
+    assert licenses.expression(cdx_license.DisjunctiveLicense(id="Apache-2.0")) == "Apache-2.0"
+    # A licence with no SPDX identifier still carries a name of its own
+    assert licenses.expression(cdx_license.DisjunctiveLicense(name="Some bespoke licence")) == "Some bespoke licence"
+
+
+def test_check() -> None:
+    good, warnings, errors = licenses.check(
+        sboms.with_components(
+            {"type": "library", "name": "permissive", "licenses": [{"license": {"id": "Apache-2.0"}}]},
+            {"type": "library", "name": "forbidden", "licenses": [{"license": {"id": "GPL-3.0-only"}}]},
+            {"type": "library", "name": "named", "licenses": [{"license": {"name": "Some bespoke licence"}}]},
+            {"type": "library", "name": "unlicensed"},
+        ),
+        include_all=True,
+    )
+
+    assert [(issue.component_name, issue.category) for issue in good] == [
+        ("permissive", models.licenses.Category.A),
+    ]
+    assert warnings == []
+    # A licence ATR cannot place is worth a look, so it may not pass by unremarked
+    assert sorted((issue.component_name, issue.category, issue.any_unknown) for issue in errors) == [
+        ("forbidden", models.licenses.Category.X, False),
+        ("named", models.licenses.Category.X, True),
+    ]


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