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