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