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

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


The following commit(s) were added to refs/heads/main by this push:
     new b36e820  Collate licenses as part of SBOM scoring and add to report
b36e820 is described below

commit b36e820784850205c62dba4846d58e7b2e7591da
Author: Alastair McFarlane <[email protected]>
AuthorDate: Mon Dec 22 12:26:12 2025 +0000

    Collate licenses as part of SBOM scoring and add to report
---
 atr/get/sbom.py             | 57 +++++++++++++++++++++++++++++++++++++++++++++
 atr/models/results.py       |  6 +++++
 atr/sbom/licenses.py        |  3 +++
 atr/sbom/models/licenses.py | 17 ++++++++++++++
 atr/tasks/sbom.py           |  8 +++++--
 5 files changed, 89 insertions(+), 2 deletions(-)

diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 8f73db3..f2ea02c 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -113,6 +113,7 @@ async def report(session: web.Committer, project: str, 
version: str, file_path:
         _augment_section(block, release, task_result, latest_augment, 
last_augmented_bom)
 
     _conformance_section(block, task_result)
+    _license_section(block, task_result)
 
     if task_result.vulnerabilities is not None:
         vulnerabilities = [
@@ -192,6 +193,26 @@ def _conformance_section(block: htm.Block, task_result: 
results.SBOMToolScore) -
         block.p["No NTIA 2021 minimum data field conformance warnings or 
errors found."]
 
 
+def _license_section(block: htm.Block, task_result: results.SBOMToolScore) -> 
None:
+    block.h2["Licenses"]
+    warnings = []
+    errors = []
+    if task_result.license_warnings is not None:
+        warnings = [sbom.models.licenses.Issue.model_validate(json.loads(w)) 
for w in task_result.license_warnings]
+    if task_result.license_errors is not None:
+        errors = [sbom.models.licenses.Issue.model_validate(json.loads(e)) for 
e in task_result.license_errors]
+    if warnings:
+        block.h3["Warnings"]
+        _license_table(block, warnings)
+
+    if errors:
+        block.h3["Errors"]
+        _license_table(block, errors)
+
+    if not (warnings or errors):
+        block.p["No license warnings or errors found."]
+
+
 def _report_header(
     block: htm.Block, is_release_candidate: bool, release: sql.Release, 
task_result: results.SBOMToolScore
 ) -> None:
@@ -293,6 +314,24 @@ def _extract_vulnerability_severity(vuln: 
osv.VulnerabilityDetails) -> str:
     return "Unknown"
 
 
+def _license_table(block: htm.Block, items: list[sbom.models.licenses.Issue]) 
-> None:
+    warning_rows = [
+        htm.tr[
+            htm.td[
+                f"Category {category!s}"
+                if (len(components) == 0)
+                else htm.details[htm.summary[f"Category {category!s}"], 
htm.div[_detail_table(components)]]
+            ],
+            htm.td[str(count)],
+        ]
+        for category, count, components in _license_tally(items)
+    ]
+    block.table(".table.table-sm.table-bordered.table-striped")[
+        htm.thead[htm.tr[htm.th["License Category"], htm.th["Count"]]],
+        htm.tbody[*warning_rows],
+    ]
+
+
 def _missing_table(block: htm.Block, items: 
list[sbom.models.conformance.Missing]) -> None:
     warning_rows = [
         htm.tr[
@@ -334,6 +373,24 @@ def _missing_tally(items: 
list[sbom.models.conformance.Missing]) -> list[tuple[s
     )
 
 
+def _license_tally(
+    items: list[sbom.models.licenses.Issue],
+) -> list[tuple[sbom.models.licenses.Category, int, list[str | None]]]:
+    counts: dict[sbom.models.licenses.Category, int] = {}
+    components: dict[sbom.models.licenses.Category, list[str | None]] = {}
+    for item in items:
+        key = item.category
+        counts[key] = counts.get(key, 0) + 1
+        if key not in components:
+            components[key] = [str(item)]
+        else:
+            components[key].append(str(item))
+    return sorted(
+        [(category, count, components.get(category, [])) for category, count 
in counts.items()],
+        key=lambda kv: kv[0].value,
+    )
+
+
 def _vulnerability_component_details_osv(block: htm.Block, component: 
results.OSVComponent) -> None:
     details_content = []
     summary_element = htm.summary[
diff --git a/atr/models/results.py b/atr/models/results.py
index 123324f..b8e8c13 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -132,6 +132,12 @@ class SBOMToolScore(schema.Strict):
     warnings: list[str] = schema.description("Warnings from the SBOM tool")
     errors: list[str] = schema.description("Errors from the SBOM tool")
     outdated: list[str] | str | None = schema.description("Outdated tool(s) 
from the SBOM tool")
+    license_warnings: list[str] | None = schema.Field(
+        default=[], strict=False, description="License warnings found in the 
SBOM"
+    )
+    license_errors: list[str] | None = schema.Field(
+        default=[], strict=False, description="License errors found in the 
SBOM"
+    )
     vulnerabilities: list[str] | None = schema.Field(
         default=None, strict=False, description="Vulnerabilities found in the 
SBOM"
     )
diff --git a/atr/sbom/licenses.py b/atr/sbom/licenses.py
index 0d1d7ae..bfed06a 100644
--- a/atr/sbom/licenses.py
+++ b/atr/sbom/licenses.py
@@ -35,6 +35,7 @@ def check(
         name = component.name or "unknown"
         version = component.version
         scope = component.scope
+        type = component.type
 
         if not component.licenses:
             continue
@@ -83,6 +84,7 @@ def check(
                         category=models.licenses.Category.X,
                         any_unknown=any_unknown,
                         scope=scope,
+                        component_type=type,
                     )
                 )
             elif got_warning:
@@ -94,6 +96,7 @@ def check(
                         category=models.licenses.Category.B,
                         any_unknown=False,
                         scope=scope,
+                        component_type=type,
                     )
                 )
 
diff --git a/atr/sbom/models/licenses.py b/atr/sbom/models/licenses.py
index 3ddb0bc..d105006 100644
--- a/atr/sbom/models/licenses.py
+++ b/atr/sbom/models/licenses.py
@@ -18,6 +18,9 @@
 from __future__ import annotations
 
 import enum
+from typing import Any
+
+import pydantic
 
 from .base import Strict
 
@@ -27,11 +30,25 @@ class Category(enum.Enum):
     B = enum.auto()
     X = enum.auto()
 
+    def __str__(self):
+        return self.name
+
 
 class Issue(Strict):
     component_name: str
     component_version: str | None
+    component_type: str | None = None
     license_expression: str
     category: Category
     any_unknown: bool = False
     scope: str | None = None
+
+    @pydantic.field_validator("category", mode="before")
+    @classmethod
+    def _coerce_property(cls, value: Any) -> Category:
+        return value if isinstance(value, Category) else Category(value)
+
+    def __str__(self):
+        type_str = "Component" if self.component_type is None else 
self.component_type
+        version_str = f"@{self.component_version}" if self.component_version 
!= "UNKNOWN" else ""
+        return f"{type_str} {self.component_name}{version_str} declares 
license {self.license_expression}"
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index 506939b..31598dc 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -220,8 +220,10 @@ async def score_tool(args: FileArgs) -> results.Results | 
None:
     bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
     version, properties = sbom.utilities.get_props_from_bundle(bundle)
     warnings, errors = sbom.conformance.ntia_2021_issues(bundle.bom)
-    # TODO: Could update the ATR version with a constant showing last change 
to the augment/scan tools
+    # TODO: Could update the ATR version with a constant showing last change 
to the augment/scan
+    #  tools so we know if it's outdated
     outdated = sbom.tool.plugin_outdated_version(bundle.bom)
+    license_warnings, license_errors = sbom.licenses.check(bundle.bom)
     vulnerabilities = sbom.osv.vulns_from_bundle(bundle)
     cli_errors = sbom.cyclonedx.validate_cli(bundle)
     return results.SBOMToolScore(
@@ -234,6 +236,8 @@ async def score_tool(args: FileArgs) -> results.Results | 
None:
         warnings=[w.model_dump_json() for w in warnings],
         errors=[e.model_dump_json() for e in errors],
         outdated=[o.model_dump_json() for o in outdated] if outdated else None,
+        license_warnings=[w.model_dump_json() for w in license_warnings] if 
license_warnings else None,
+        license_errors=[e.model_dump_json() for e in license_errors] if 
license_errors else None,
         vulnerabilities=[v.model_dump_json() for v in vulnerabilities],
         atr_props=properties,
         cli_errors=cli_errors,
@@ -284,7 +288,7 @@ async def _generate_cyclonedx_core(artifact_path: str, 
output_path: str) -> dict
         log.info(f"Using root directory: {extract_dir}")
 
         # Run syft to generate the CycloneDX SBOM
-        syft_command = ["syft", extract_dir, "-o", "cyclonedx-json", 
"--base-path", f"{temp_dir!s}"]
+        syft_command = ["syft", extract_dir, "-o", "cyclonedx-json", 
"--enrich", "all", "--base-path", f"{temp_dir!s}"]
         log.info(f"Running syft: {' '.join(syft_command)}")
 
         try:


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to