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

(tooling-trusted-releases) branch sbp updated (dcfdf180 -> ee3ceb01)

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

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


    from dcfdf180 Update dependencies
     add 3ab54f8e #1577 - update database docs
     new ee3ceb01 Display more details about concerns in SBOM report pages

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 atr/docs/database.md                    |  75 ++++++++--
 atr/get/sbom.py                         | 198 ++++++++++++++++++++++++-
 atr/sbom/licenses.py                    |   1 +
 atr/static/css/atr.css                  |  17 +++
 atr/tasks/checks/sbom.py                |  36 ++---
 tests/unit/test_get_sbom_review.py      | 250 ++++++++++++++++++++++++++++++++
 tests/unit/test_sbom_licenses.py        |  29 ++++
 tests/unit/test_sbom_review.py          |  16 +-
 typestubs/cyclonedx/model/component.pyi |   4 +
 9 files changed, 572 insertions(+), 54 deletions(-)
 create mode 100644 tests/unit/test_get_sbom_review.py


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


(tooling-trusted-releases) 01/01: Display more details about concerns in SBOM report pages

Posted by sb...@apache.org.
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

commit ee3ceb0198ada8602d821cb81766904703a7c94a
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Tue Sep 15 19:06:27 2026 +0100

    Display more details about concerns in SBOM report pages
---
 atr/get/sbom.py                         | 198 ++++++++++++++++++++++++-
 atr/sbom/licenses.py                    |   1 +
 atr/static/css/atr.css                  |  17 +++
 atr/tasks/checks/sbom.py                |  36 ++---
 tests/unit/test_get_sbom_review.py      | 250 ++++++++++++++++++++++++++++++++
 tests/unit/test_sbom_licenses.py        |  29 ++++
 tests/unit/test_sbom_review.py          |  16 +-
 typestubs/cyclonedx/model/component.pyi |   4 +
 8 files changed, 512 insertions(+), 39 deletions(-)

diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 1f2d0ff3..f4c9fbf1 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -27,7 +27,9 @@ import markupsafe
 
 import atr.blueprints.get as get
 import atr.db as db
+import atr.db.interaction as interaction
 import atr.get.compose as compose
+import atr.get.report as report
 import atr.get.vote as vote
 import atr.htm as htm
 import atr.log as log
@@ -64,6 +66,9 @@ async def quality(
 
     task = await _score_task(str(file_path), project_key, release, version_key)
     task_result = _score_result(task)
+    review_task, findings = await _review(release, file_path)
+    report_url = util.as_url(report.selected_path, project_key=project_key, version_key=version_key, rel_path=file_path)
+    _review_summary(block, review_task, findings, report_url)
 
     block.h2["Content"]
     breakdown = await _breakdown(base_path, file_path)
@@ -72,10 +77,14 @@ async def quality(
         block.p["This SBOM could not be read, so its components cannot be shown."]
     else:
         _components_section(block, breakdown)
-    _license_section(block, task)
+    _license_section(block, task, findings.get("licenses"))
+    _maintenance_section(block, findings.get("risk"), review_task, release.is_embargoed)
     _vulnerability_section(block, task, task_result)
 
     block.h2["Quality"]
+    if structure := findings.get("structure"):
+        block.h3("#sbom-review-structure")[_review_badge(), "SBOM structure"]
+        block.p[structure.message]
     # 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)]
@@ -310,13 +319,10 @@ def _license_group_label(category: sbom.models.licenses.Category, any_unknown: b
     return f"Category {category!s} (unrecognised license)" if any_unknown else f"Category {category!s}"
 
 
-def _license_section(block: htm.Block, task: sql.Task | None) -> None:
-    block.h3["Licenses"]
+def _license_section(block: htm.Block, task: sql.Task | None, finding: sql.CheckResult | None = None) -> None:
+    block.h3("#sbom-review-licenses")["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
     block.p[
         "Categories follow the ",
         htm.a(href="https://www.apache.org/legal/resolved.html")["ASF third party license policy"],
@@ -328,6 +334,11 @@ def _license_section(block: htm.Block, task: sql.Task | None) -> None:
         htm.a(href="https://github.com/apache/tooling-trusted-release/issues")["ATR"],
         ".",
     ]
+    if finding is not None:
+        block.p[_review_badge(), _review_label("licenses", finding)]
+    if task_result is None:
+        block.p[_score_unavailable(task)]
+        return
     warnings = []
     errors = []
     prev_licenses = None
@@ -421,6 +432,83 @@ def _load_license_issues(issues: list[str]) -> list[sbom.models.licenses.Issue]:
     return [sbom.models.licenses.Issue.model_validate(json.loads(i)) for i in issues]
 
 
+def _maintenance_details(item: dict[str, Any]) -> htm.Element:
+    observations = (
+        ("Criticality", "criticality"),
+        ("Health", "health"),
+        ("Health inputs observed (of 4)", "health_inputs"),
+        ("Contributor distribution (DDS)", "dds"),
+        ("Governance files (of 3)", "governance_files"),
+        ("Average package ranking", "rankings_average"),
+        ("Repository URL", "repository_url"),
+        ("Inferred mirror", "inferred_mirror"),
+    )
+    return htm.details[
+        htm.summary["Score and source details"],
+        _review_table(
+            ["Observation", "Value"],
+            [htm.tr[htm.th[label], htm.td[_review_value(item.get(key))]] for label, key in observations],
+        ),
+    ]
+
+
+def _maintenance_section(
+    block: htm.Block, finding: sql.CheckResult | None, task: sql.Task | None, embargoed: bool
+) -> None:
+    block.h3("#sbom-review-risk")["Maintenance"]
+    if finding is None:
+        if embargoed:
+            block.p["Maintenance lookups are skipped for embargoed releases."]
+        elif (task is not None) and (task.status == sql.TaskStatus.COMPLETED):
+            block.p["No maintenance concern recorded. Coverage is unavailable for this review."]
+        else:
+            block.p[_review_status(task)]
+        return
+    data = finding.data
+    block.p(".border-top.border-bottom.py-2")[
+        f"{data['scored']}/{data['packages']} packages in the SBOM were given risk scores."
+    ]
+    block.p[_review_badge(), _review_label("risk", finding)]
+    rows = [
+        htm.tr[
+            htm.td[
+                htm.code[item["key"].removeprefix("pkg:")],
+                htm.details[
+                    htm.summary["Package URLs and versions"],
+                    [htm.div[htm.code[purl]] for purl in item["source_purls"]],
+                ],
+            ],
+            htm.td[f"{item['risk']:.3f}"],
+            htm.td[
+                htm.div[f"Repository archived: {_review_value(item.get('archived'))}"],
+                htm.div[f"Active maintainers: {_review_value(item.get('active_maintainers'))}"],
+                htm.div[f"Latest release: {_review_value(item.get('latest_release_at'))}"],
+                _maintenance_details(item),
+            ],
+        ]
+        for item in data["risks"]
+    ]
+    block.table(".table.table-sm.table-bordered.table-striped")[
+        htm.thead[htm.tr[htm.th["Maintenance risk"], htm.th["Count"]]],
+        htm.tbody[
+            htm.tr[
+                htm.td[
+                    htm.details[
+                        htm.summary[f"Over {data['threshold']:g}"],
+                        htm.div[_review_table(["Package", "Risk (rounded)", "Supporting observations"], rows)],
+                    ],
+                ],
+                htm.td[str(data["risk_count"])],
+            ],
+        ],
+    ]
+    if data["risks_truncated"]:
+        block.p(".small.text-muted")[
+            f"These are the top {len(rows)} riskiest dependencies over {data['threshold']:g}. "
+            f"There are {data['risk_count']} total dependencies over {data['threshold']:g} risk."
+        ]
+
+
 def _missing_table(block: htm.Block, items: list[sbom.models.conformance.Missing]) -> None:
     warning_rows = [
         htm.tr[
@@ -522,6 +610,104 @@ def _phase_nav(block: htm.Block, release: sql.Release) -> None:
     )
 
 
+async def _review(release: sql.Release, file_path: safe.RelPath) -> tuple[sql.Task | None, dict[str, sql.CheckResult]]:
+    async with db.session() as data:
+        via = sql.validate_instrumented_attribute
+        task = (
+            await data.task(
+                project_key=release.project_key,
+                version_key=release.version,
+                revision_number=release.latest_revision_number,
+                task_type=sql.TaskType.SBOM_REVIEW,
+                primary_rel_path=str(file_path),
+            )
+            .order_by(via(sql.Task.added).desc(), via(sql.Task.id).desc())
+            .limit(1)
+            .get()
+        )
+        rows = await interaction.check_results_for_revision(
+            release.safe_project_key,
+            release.safe_version_key,
+            release.safe_latest_revision_number,
+            checker="atr.tasks.checks.sbom.review",
+            rel_path=str(file_path),
+            member=False,
+            statuses=[sql.CheckResultStatus.CONCERN, sql.CheckResultStatus.EXCEPTION],
+            caller_data=data,
+        )
+    findings = {}
+    for row in rows:
+        kind = "exception" if (row.status == sql.CheckResultStatus.EXCEPTION) else row.data.get("finding", "structure")
+        findings.setdefault(kind, row)
+    return task, findings
+
+
+def _review_badge(status: sql.CheckResultStatus = sql.CheckResultStatus.CONCERN) -> htm.Element:
+    colour = "warning" if (status == sql.CheckResultStatus.CONCERN) else "danger"
+    return htm.span(f".badge.bg-{colour}-subtle.text-{colour}-emphasis.fw-medium.me-2")[status.value.capitalize()]
+
+
+def _review_label(kind: str, finding: sql.CheckResult) -> str:
+    data = finding.data
+    if kind == "licenses":
+        return f"Category X licences in {util.plural(data['license_count'], 'dependency', 'dependencies')}."
+    if kind == "risk":
+        return f"{util.plural(data['risk_count'], 'package')} with maintenance risk above {data['threshold']:g}."
+    return finding.message
+
+
+def _review_status(task: sql.Task | None) -> str:
+    if task is None:
+        return "SBOM review has not run for this revision."
+    match task.status:
+        case sql.TaskStatus.QUEUED:
+            return "SBOM review is queued."
+        case sql.TaskStatus.ACTIVE:
+            return "SBOM review is running."
+        case sql.TaskStatus.COMPLETED:
+            return "SBOM review completed for this revision."
+        case sql.TaskStatus.FAILED | sql.TaskStatus.BROKEN:
+            return "SBOM review did not complete."
+
+
+def _review_summary(
+    block: htm.Block, task: sql.Task | None, findings: dict[str, sql.CheckResult], report_url: str
+) -> None:
+    count = sum(row.status == sql.CheckResultStatus.CONCERN for row in findings.values())
+    with block.block(htm.div(".atr-sbom-review")) as summary:
+        summary.h3(".mt-0")[f"SBOM review ยท {util.plural(count, 'concern')} recorded"]
+        rows = [
+            htm.tr[
+                htm.td(".text-nowrap")[_review_badge(findings[kind].status)],
+                htm.td[
+                    htm.a(href=report_url if (kind == "exception") else f"#sbom-review-{kind}")[
+                        _review_label(kind, findings[kind])
+                    ]
+                ],
+            ]
+            for kind in ("licenses", "risk", "structure", "exception")
+            if kind in findings
+        ]
+        if rows:
+            summary.append(_review_table(["Status", "Finding"], rows))
+        summary.p(".small.mb-0")[_review_status(task), " ", htm.a(href=report_url)["View check report"]]
+
+
+def _review_table(headers: list[str], rows: list[htm.Element]) -> htm.Element:
+    return htm.table(".table.table-sm.atr-sbom-table")[
+        htm.thead[htm.tr[[htm.th(scope="col")[header] for header in headers]]],
+        htm.tbody[*rows],
+    ]
+
+
+def _review_value(value: Any) -> str:
+    if value is None:
+        return "Unknown"
+    if isinstance(value, bool):
+        return "Yes" if value else "No"
+    return f"{value:.3f}" if isinstance(value, float) else str(value)
+
+
 def _score_result(task: sql.Task | None) -> results.SBOMToolScore | None:
     if task is None:
         return None
diff --git a/atr/sbom/licenses.py b/atr/sbom/licenses.py
index ab0de6ea..63b4a674 100644
--- a/atr/sbom/licenses.py
+++ b/atr/sbom/licenses.py
@@ -57,6 +57,7 @@ def check(
         components = [bom_value.metadata.component, *components]
 
     for component in components:
+        components.extend(component.components)
         name = component.name or "unknown"
         version = component.version
         scope = component.scope
diff --git a/atr/static/css/atr.css b/atr/static/css/atr.css
index ecd48904..43a2a255 100644
--- a/atr/static/css/atr.css
+++ b/atr/static/css/atr.css
@@ -1247,6 +1247,23 @@ td.atr-checkbox-col {
     text-align: center;
 }
 
+.atr-sbom-review {
+    background-color: #fffaf0;
+    border: 1px solid #ddb968;
+    padding: 1rem;
+    margin-bottom: 1.5rem;
+}
+
+table.atr-sbom-table th,
+table.atr-sbom-table td {
+    padding: 0.65rem 0.5rem;
+    vertical-align: top;
+    font-family: inherit;
+    font-size: inherit;
+    overflow-wrap: anywhere;
+    word-break: normal;
+}
+
 .atr-text-blocker .atr-classification-badge {
     background-color: #fdf6f6;
 }
diff --git a/atr/tasks/checks/sbom.py b/atr/tasks/checks/sbom.py
index 04ef4e2f..449839b1 100644
--- a/atr/tasks/checks/sbom.py
+++ b/atr/tasks/checks/sbom.py
@@ -38,10 +38,9 @@ import atr.tasks.task as task
 INPUT_POLICY_KEYS: Final[list[str]] = []
 INPUT_EXTRA_ARGS: Final[list[str]] = ["suffixed_file_existence"]
 CHECK_VERSION: Final[str] = "3"
-REVIEW_VERSION: Final = "3"
+REVIEW_VERSION: Final = "4"
 RISK_THRESHOLD: Final = 0.6
 _DEADLINE_SECONDS: Final = 540
-_EVIDENCE_LIMIT: Final = 100
 
 
 async def check(args: checks.FunctionArguments) -> results.Results | None:
@@ -136,41 +135,32 @@ def _headline(names: list[str]) -> str:
     return f"Packages {listed}{remainder}"
 
 
-def _license_name(item: dict[str, Any]) -> str:
-    key = maintenance.package_key(item["purl"]) if item["purl"] else None
-    name = (key or item["name"]).removeprefix("pkg:")
-    return f"{name}@{item['version']}" if item["version"] else name
+def _license_name(component: streaming.LicensedComponent) -> str:
+    key = maintenance.package_key(component.purl) if component.purl else None
+    name = (key or component.name).removeprefix("pkg:")
+    return f"{name}@{component.version}" if component.version else name
 
 
 async def _licenses(recorder: checks.Recorder, inventory: streaming.Inventory) -> None:
     flagged = []
+    any_unknown = False
     for component in inventory.licensed_components:
         choices = [licenses.assess(expression, explicit) for expression, explicit in component.licenses]
         rejected = [choice for choice in choices if (choice.category == atr.sbom.models.licenses.Category.X)]
         if not rejected:
             continue
-        flagged.append(
-            {
-                "name": component.name,
-                "version": component.version,
-                "purl": component.purl,
-                "expressions": sorted({choice.expression for choice in rejected}),
-                "any_unknown": any(choice.any_unknown for choice in rejected),
-            }
-        )
+        flagged.append(_license_name(component))
+        any_unknown |= any(choice.any_unknown for choice in rejected)
     if not flagged:
         return
-    flagged.sort(key=_license_name)
-    names = [_license_name(item) for item in flagged]
-    unknown = " (including unrecognised licences)" if any(item["any_unknown"] for item in flagged) else ""
+    flagged.sort()
+    unknown = " (including unrecognised licences)" if any_unknown else ""
     await recorder.concern(
-        f"{_headline(names)} have Category X licences{unknown}",
+        f"{_headline(flagged)} have Category X licences{unknown}",
         {
             "finding": "licenses",
             "licensed_components": len(inventory.licensed_components),
             "license_count": len(flagged),
-            "licenses": flagged[:_EVIDENCE_LIMIT],
-            "licenses_truncated": len(flagged) > _EVIDENCE_LIMIT,
         },
     )
 
@@ -214,8 +204,8 @@ async def _risks(recorder: checks.Recorder, inventory: streaming.Inventory) -> N
             "scored": scored,
             "unknown": len(sources) - scored,
             "risk_count": len(flagged),
-            "risks": flagged[:_EVIDENCE_LIMIT],
-            "risks_truncated": len(flagged) > _EVIDENCE_LIMIT,
+            "risks": flagged,
+            "risks_truncated": False,
             "attribution": "ecosyste.ms (CC BY-SA 4.0) with inferred Apache Gitbox mirrors; no deps.dev source veto",
         },
     )
diff --git a/tests/unit/test_get_sbom_review.py b/tests/unit/test_get_sbom_review.py
new file mode 100644
index 00000000..f3c4a8dd
--- /dev/null
+++ b/tests/unit/test_get_sbom_review.py
@@ -0,0 +1,250 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import inspect
+import unittest.mock as mock
+
+import pytest
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.db as db
+import atr.get.sbom as sbom
+import atr.htm as htm
+import atr.models.results as results
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.sbom.models.licenses as licenses
+
+
+@pytest.mark.parametrize("with_score", [False, True])
+async def test_license_concern_uses_full_score_table_without_duplicate_evidence(monkeypatch, with_score):
+    licences = _finding(
+        {
+            "finding": "licenses",
+            "license_count": 120,
+            "licensed_components": 200,
+        }
+    )
+    exception = _finding(None, sql.CheckResultStatus.EXCEPTION)
+    exception.message = "Maintenance lookup did not complete"
+    score = results.SBOMToolScore(
+        kind="sbom_tool_score",
+        project_key=safe.ProjectKey("test"),
+        version_key=safe.VersionKey("1.0"),
+        revision_number=safe.RevisionNumber("00001"),
+        file_path=safe.RelPath("sbom.cdx.json"),
+        warnings=[],
+        errors=[],
+        outdated=None,
+        cli_errors=None,
+        license_errors=[
+            licenses.Issue(
+                component_name=f"<script>x</script>-{i}",
+                component_version="1",
+                license_expression="GPL-3.0-only",
+                category=licenses.Category.X,
+            ).model_dump_json()
+            for i in range(120)
+        ],
+    )
+    page = await _page(
+        monkeypatch,
+        {"licenses": licences, "exception": exception},
+        sql.TaskStatus.BROKEN,
+        score=score if with_score else None,
+    )
+    assert "1 concern recorded" in page
+    assert "SBOM review did not complete" in page
+    assert exception.message in page
+    assert ">Exception</span>" in page
+    assert 'href="#sbom-review-licenses">Category X licences in 120 dependencies.</a>' in page
+    assert licences.message not in page
+    assert 'id="sbom-review-licenses"' in page
+    assert "Category X licences in 120 dependencies." in page
+    assert "Declared expression" not in page
+    if with_score:
+        assert "&lt;script&gt;x&lt;/script&gt;-119@1" in page
+        assert "<script>" not in page
+        assert page.count("License Category") == 1
+    else:
+        assert "No SBOM score found" in page
+        assert "License Category" not in page
+
+
+@pytest.mark.parametrize("truncated", [False, True])
+def test_maintenance_shows_all_stored_rows_coverage_and_sources(truncated):
+    data = {
+        "finding": "risk",
+        "threshold": 0.6,
+        "packages": 200,
+        "scored": 180,
+        "unknown": 20,
+        "risk_count": 150,
+        "risks_truncated": truncated,
+        "generated_at": "2026-09-15T13:00:00+00:00",
+        "attribution": "ecosyste.ms (CC BY-SA 4.0)",
+        "risks": [
+            {
+                "key": f"pkg:npm/package-{i}",
+                "source_purls": [f"pkg:npm/package-{i}@1"],
+                "risk": 0.855,
+                "archived": True,
+                "active_maintainers": 0,
+                "repository_url": "https://example.org/repository",
+            }
+            for i in range(100 if truncated else 150)
+        ],
+    }
+    block = htm.Block()
+    finding = _finding(data)
+    sbom._review_summary(block, _task(), {"risk": finding}, "/report")
+    sbom._maintenance_section(block, finding, _task(), False)
+    page = str(block.collect())
+    assert 'href="#sbom-review-risk">150 packages with maintenance risk above 0.6.</a>' in page
+    assert finding.message not in page
+    assert "180/200 packages in the SBOM were given risk scores." in page
+    assert "pkg:npm/package-99@1" in page
+    assert "0.855" in page
+    assert "Active maintainers: 0" in page
+    assert "Latest release: Unknown" in page
+    assert "https://example.org/repository" in page
+    assert "<summary>Over 0.6</summary>" in page
+    if truncated:
+        assert (
+            "These are the top 100 riskiest dependencies over 0.6. There are 150 total dependencies over 0.6 risk."
+        ) in page
+    else:
+        assert "pkg:npm/package-149@1" in page
+        assert "These are the top" not in page
+
+
+@pytest.mark.parametrize(
+    ("status", "embargoed", "expected"),
+    [
+        (None, False, "has not run"),
+        (sql.TaskStatus.QUEUED, False, "queued"),
+        (sql.TaskStatus.ACTIVE, False, "running"),
+        (sql.TaskStatus.COMPLETED, False, "Coverage is unavailable"),
+        (sql.TaskStatus.COMPLETED, True, "skipped for embargoed releases"),
+    ],
+)
+def test_maintenance_without_findings_does_not_invent_coverage(status, embargoed, expected):
+    block = htm.Block()
+    sbom._maintenance_section(block, None, _task(status) if status else None, embargoed)
+    page = str(block.collect())
+    assert expected in page
+    assert "scored" not in page
+    assert "healthy" not in page
+
+
+async def test_review_uses_current_check_hash_and_newest_task(monkeypatch):
+    engine = sqlalchemy.ext.asyncio.create_async_engine("sqlite+aiosqlite:///:memory:")
+    async with engine.begin() as connection:
+        await connection.run_sync(sqlmodel.SQLModel.metadata.create_all)
+    sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(engine, class_=db.Session, expire_on_commit=False)
+    monkeypatch.setattr(db, "session", sessionmaker)
+    monkeypatch.setattr(
+        sbom.interaction.attestable,
+        "load_checks",
+        mock.AsyncMock(return_value={"sbom.cdx.json": {"atr.tasks.checks.sbom.review": "current"}}),
+    )
+    older = _task(sql.TaskStatus.COMPLETED)
+    older.completed = older.added
+    queued = _task(sql.TaskStatus.QUEUED)
+    queued.added = older.added + datetime.timedelta(seconds=1)
+    wrong_path = _task(sql.TaskStatus.QUEUED)
+    wrong_path.added = queued.added + datetime.timedelta(seconds=1)
+    wrong_path.primary_rel_path = "other.cdx.json"
+    wrong_revision = _task(sql.TaskStatus.QUEUED)
+    wrong_revision.added = wrong_path.added + datetime.timedelta(seconds=1)
+    wrong_revision.revision_number = "00002"
+    current = _finding({"problem": "Current problem"})
+    current.inputs_hash = "current"
+    stale = _finding({"problem": "Stale problem"})
+    stale.inputs_hash = "stale"
+    try:
+        async with sessionmaker() as data:
+            data.add_all([older, queued, wrong_path, wrong_revision, current, stale])
+            await data.commit()
+        task, findings = await sbom._review(_release(), safe.RelPath("sbom.cdx.json"))
+        assert task.id == queued.id
+        assert findings["structure"].id == current.id
+    finally:
+        await engine.dispose()
+
+
+async def test_structure_remains_visible_without_tool_score(monkeypatch):
+    page = await _page(monkeypatch, {"structure": _finding({"problem": "Duplicate components"})})
+    assert 'href="#sbom-review-structure"' in page
+    assert 'id="sbom-review-structure"' in page
+    assert page.count("Duplicate components") == 2
+    assert "No SBOM score found" in page
+
+
+def _finding(data, status=sql.CheckResultStatus.CONCERN):
+    return sql.CheckResult(
+        release_key="test-1.0",
+        revision_number="00001",
+        checker="atr.tasks.checks.sbom.review",
+        primary_rel_path="sbom.cdx.json",
+        created=datetime.datetime.now(datetime.UTC),
+        status=status,
+        message=data.get("problem", "Packages a, b, c (and 117 more)") if data else "Lookup failed",
+        data=data,
+    )
+
+
+async def _page(monkeypatch, findings, status=sql.TaskStatus.COMPLETED, score=None):
+    monkeypatch.setattr(sbom.shared.sbom, "release_in_phase", mock.AsyncMock(return_value=_release()))
+    score_task = sql.Task(task_type=sql.TaskType.SBOM_TOOL_SCORE, result=score) if score else None
+    monkeypatch.setattr(sbom, "_score_task", mock.AsyncMock(return_value=score_task))
+    monkeypatch.setattr(sbom, "_breakdown", mock.AsyncMock(return_value=None))
+    monkeypatch.setattr(sbom, "_review", mock.AsyncMock(return_value=(_task(status), findings)))
+    monkeypatch.setattr(sbom.util, "as_url", mock.Mock(return_value="/report/test/1.0/sbom.cdx.json"))
+    blank = mock.AsyncMock()
+    monkeypatch.setattr(sbom.template, "blank", blank)
+    handler = inspect.getclosurevars(inspect.unwrap(sbom.quality)).nonlocals["func"]
+    await handler(None, "sbom/quality", safe.ProjectKey("test"), safe.VersionKey("1.0"), safe.RelPath("sbom.cdx.json"))
+    return str(blank.call_args.kwargs["content"])
+
+
+def _release():
+    release = sql.Release(
+        key="test-1.0",
+        project_key="test",
+        version="1.0",
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+        created=datetime.datetime.now(datetime.UTC),
+    )
+    release.project = sql.Project(key="test", name="Apache Test")
+    release._latest_revision_number = "00001"
+    return release
+
+
+def _task(status=sql.TaskStatus.COMPLETED):
+    return sql.Task(
+        task_type=sql.TaskType.SBOM_REVIEW,
+        status=status,
+        task_args={},
+        asf_uid="test",
+        project_key="test",
+        version_key="1.0",
+        revision_number="00001",
+        primary_rel_path="sbom.cdx.json",
+    )
diff --git a/tests/unit/test_sbom_licenses.py b/tests/unit/test_sbom_licenses.py
index f9758a25..4331f7b7 100644
--- a/tests/unit/test_sbom_licenses.py
+++ b/tests/unit/test_sbom_licenses.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import cyclonedx.model.license as cdx_license
+import pytest
 
 import atr.sbom.constants as constants
 import atr.sbom.licenses as licenses
@@ -167,6 +168,34 @@ def test_check_a_disjunction_settles_on_the_friendliest_category() -> None:
     assert errors == []
 
 
+@pytest.mark.parametrize("subject", [False, True])
+@pytest.mark.parametrize("source", [False, True])
+def test_check_includes_nested_components(subject, source) -> None:
+    parent = {
+        "type": "library",
+        "name": "unlicensed-parent",
+        "components": [
+            {"type": "library", "name": "forbidden", "licenses": [{"license": {"id": "GPL-3.0-only"}}]},
+            {
+                "type": "library",
+                "name": "weak-copyleft",
+                "licenses": [{"license": {"id": "EPL-2.0"}}],
+                "components": [
+                    {"type": "library", "name": "permissive", "licenses": [{"license": {"id": "MIT"}}]},
+                ],
+            },
+        ],
+    }
+    bom = sboms.build({"metadata": {"component": parent}} if subject else {"components": [parent]})
+    good, warnings, errors = licenses.check(bom, include_all=True, is_source_release=source)
+
+    assert [issue.component_name for issue in good] == ["permissive"]
+    assert [issue.component_name for issue in warnings] == ([] if source else ["weak-copyleft"])
+    assert sorted(issue.component_name for issue in errors) == (
+        ["forbidden", "weak-copyleft"] if source else ["forbidden"]
+    )
+
+
 def test_check_records_the_chosen_half_of_a_disjunction_it_still_flags() -> None:
     # "B OR X" still warns, but only on the friendlier B half, and the report keeps the half it settled on
     _good, warnings, _errors = licenses.check(
diff --git a/tests/unit/test_sbom_review.py b/tests/unit/test_sbom_review.py
index 24fe8246..3318dbbc 100644
--- a/tests/unit/test_sbom_review.py
+++ b/tests/unit/test_sbom_review.py
@@ -122,9 +122,7 @@ async def test_review_aggregates_x_and_unknown_licenses_separately_from_risk(rev
         "Packages npm/a@1, unknown, x@2 have Category X licences (including unrecognised licences)"
     )
     data = recorder.messages[0][2]
-    assert (data["licensed_components"], data["license_count"], data["licenses_truncated"]) == (5, 3, False)
-    assert [item["any_unknown"] for item in data["licenses"]] == [False, True, False]
-    assert data["licenses"][2]["expressions"] == ["MIT AND GPL-3.0-only"]
+    assert data == {"finding": "licenses", "licensed_components": 5, "license_count": 3}
     assert [message[2]["finding"] for message in recorder.messages] == ["licenses", "risk"]
     assert "risks" not in data
     assert "licenses" not in recorder.messages[1][2]
@@ -174,7 +172,7 @@ async def test_review_emits_only_structural_concerns(review_input, monkeypatch,
 
 
 @pytest.mark.parametrize("count", [1, 3, 105])
-async def test_review_headline_and_evidence_cap(review_input, monkeypatch, count) -> None:
+async def test_review_headline_and_full_risk_evidence(review_input, monkeypatch, count) -> None:
     path, recorder, args = review_input
     keys = [f"pkg:npm/p{i:03}" for i in range(count)]
     path.write_bytes(_document(list(reversed(keys))))
@@ -189,8 +187,8 @@ async def test_review_headline_and_evidence_cap(review_input, monkeypatch, count
     remainder = f" (and {count - 3} more)" if (count > 3) else ""
     assert message == f"Packages {names}{remainder} have maintenance risk above 0.6"
     assert data["risk_count"] == count
-    assert len(data["risks"]) == min(count, 100)
-    assert data["risks_truncated"] == (count > 100)
+    assert [row["key"] for row in data["risks"]] == keys
+    assert data["risks_truncated"] is False
 
 
 async def test_review_internal_deadline_is_operational(review_input, monkeypatch) -> None:
@@ -203,7 +201,7 @@ async def test_review_internal_deadline_is_operational(review_input, monkeypatch
     assert recorder.messages == []
 
 
-async def test_review_license_evidence_cap_preserves_total_and_headline(review_input) -> None:
+async def test_review_license_summary_preserves_total_and_headline(review_input) -> None:
     path, recorder, args = review_input
     document = json.loads(_LICENSED)
     document["components"] = [
@@ -214,9 +212,7 @@ async def test_review_license_evidence_cap_preserves_total_and_headline(review_i
     assert len(recorder.messages) == 1
     _status, message, data = recorder.messages[0]
     assert message == "Packages p000, p001, p002 (and 102 more) have Category X licences"
-    assert data["license_count"] == 105
-    assert len(data["licenses"]) == 100
-    assert data["licenses_truncated"] is True
+    assert data == {"finding": "licenses", "licensed_components": 105, "license_count": 105}
 
 
 @pytest.mark.parametrize("error", [OSError("unreadable"), streaming.LimitError("limit")])
diff --git a/typestubs/cyclonedx/model/component.pyi b/typestubs/cyclonedx/model/component.pyi
index 6ff78ec9..6141dc8d 100644
--- a/typestubs/cyclonedx/model/component.pyi
+++ b/typestubs/cyclonedx/model/component.pyi
@@ -1,3 +1,4 @@
+import collections.abc as abc
 from enum import Enum
 from typing import Any, Optional
 
@@ -46,3 +47,6 @@ class Component(_JsonSerializable, _XmlSerializable):
     bom_ref: BomRef
 
     def __init__(self, *, component_type: ComponentType, name: str, **kwargs: Any) -> None: ...
+
+    @property
+    def components(self) -> abc.Sequence[Component]: ...


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