This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git
The following commit(s) were added to refs/heads/main by this push:
new 0f93718 Add the ability to check for outdated plugins in the SBOM tool
0f93718 is described below
commit 0f93718743fa3d3a537b92d3d9aedf0acd6e62b2
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Sep 1 16:58:19 2025 +0100
Add the ability to check for outdated plugins in the SBOM tool
---
atr/sbomtool.py | 212 +++++++++++++++++++++++++++++++++++++++++---
scripts/github_tag_dates.py | 115 ++++++++++++++++++++++++
2 files changed, 313 insertions(+), 14 deletions(-)
diff --git a/atr/sbomtool.py b/atr/sbomtool.py
index b55c4e9..a226edb 100644
--- a/atr/sbomtool.py
+++ b/atr/sbomtool.py
@@ -15,6 +15,7 @@
# specific language governing permissions and limitations
# under the License.
+
from __future__ import annotations
import dataclasses
@@ -48,6 +49,62 @@ KNOWN_PURL_SUPPLIERS: Final[dict[tuple[str, str], tuple[str,
str]]] = {
("pkg:maven", "org.opensaml"): ("The Shibboleth Consortium",
"https://www.shibboleth.net/"),
("pkg:maven", "org.osgi"): ("OSGi Working Group, The Eclipse Foundation",
"https://www.osgi.org/"),
}
+# TODO: Manually updated for now
+# Use GITHUB_TOKEN=... uv run python3 scripts/github_tag_dates.py
CycloneDX/cyclonedx-maven-plugin
+MAVEN_PLUGIN_VERSIONS: Final[dict[str, str]] = {
+ "2024-11-28T21:29:12Z": "2.9.1",
+ "2024-10-08T04:31:11Z": "2.9.0",
+ "2024-09-25T20:08:34Z": "2.8.2",
+ "2024-08-03T22:37:32Z": "2.8.1",
+ "2024-03-23T12:35:22Z": "2.8.0",
+ "2024-01-16T08:02:43Z": "2.7.11",
+ "2023-10-30T00:44:15Z": "2.7.10",
+ "2023-05-16T18:58:36Z": "2.7.9",
+ "2023-04-25T19:47:56Z": "2.7.8",
+ "2023-04-17T22:41:32Z": "2.7.7",
+ "2023-03-30T21:58:15Z": "2.7.6",
+ "2023-02-15T23:43:55Z": "2.7.5",
+ "2023-01-04T20:24:45Z": "2.7.4",
+ "2022-11-10T07:37:12Z": "2.7.3",
+ "2022-10-10T14:23:53Z": "2.7.2",
+ "2022-07-20T04:23:22Z": "2.7.1",
+ "2022-05-26T13:55:36Z": "2.7.0",
+ "2022-05-03T14:19:50Z": "2.6.2",
+ "2022-05-03T02:39:34Z": "2.6.1",
+ "2022-04-30T05:28:10Z": "2.6.0",
+ "2021-09-03T02:00:01Z": "2.5.3",
+ "2021-08-19T05:29:24Z": "2.5.2",
+ "2021-05-17T06:09:59Z": "2.5.1",
+ "2021-05-16T06:14:27Z": "2.5.0",
+ "2021-04-09T04:58:23Z": "2.4.1",
+ "2021-04-01T04:09:47Z": "2.4.0",
+ "2021-03-05T03:12:42Z": "2.3.0",
+ "2021-01-30T23:42:19Z": "2.2.0",
+ "2020-11-19T04:57:18Z": "2.1.1",
+ "2020-10-12T20:09:06Z": "2.1.0",
+ "2020-08-11T03:36:18Z": "2.0.3",
+ "2020-07-20T01:41:20Z": "2.0.2",
+ "2020-07-15T16:42:24Z": "2.0.1",
+ "2020-07-14T03:45:56Z": "2.0.0",
+ "2020-02-07T04:38:47Z": "1.6.4",
+ "2020-02-01T04:51:27Z": "1.6.3",
+ "2020-01-27T23:45:33Z": "1.6.2",
+ "2020-01-24T21:33:32Z": "1.6.1",
+ "2020-01-08T05:33:32Z": "1.6.0",
+ "2019-11-26T21:07:06Z": "1.5.1",
+ "2019-11-20T05:13:32Z": "1.5.0",
+ "2019-06-19T16:41:47Z": "1.4.1",
+ "2019-06-08T05:04:41Z": "1.4.0",
+ "2019-01-02T20:44:14Z": "1.3.1",
+ "2018-12-05T01:56:08Z": "1.3.0",
+ "2018-11-28T00:27:19Z": "1.2.0",
+ "2018-11-09T04:28:00Z": "1.1.3",
+ "2018-07-25T20:54:37Z": "1.1.2",
+ "2018-07-18T02:59:25Z": "1.1.1",
+ "2018-06-07T04:20:23Z": "1.1.0",
+ "2018-05-24T23:24:10Z": "1.0.1",
+ "2018-05-02T16:34:05Z": "1.0.0",
+}
THE_APACHE_SOFTWARE_FOUNDATION: Final[str] = "The Apache Software Foundation"
VERSION: Final[str] = "0.0.1-dev1"
@@ -133,11 +190,27 @@ class Component(Lax):
swid: Swid | None = None
+class ToolComponent(Lax):
+ name: str | None = None
+ version: str | None = None
+
+
+class Tool(Lax):
+ vendor: str | None = None
+ name: str | None = None
+ version: str | None = None
+
+
+class Tools(Lax):
+ components: list[ToolComponent] | None = None
+
+
class Metadata(Lax):
author: str | None = None
timestamp: str | None = None
supplier: Supplier | None = None
component: Component | None = None
+ tools: Tools | list[Tool] | None = None
class Dependency(Lax):
@@ -167,6 +240,9 @@ class ComponentProperty(enum.Enum):
IDENTIFIER = enum.auto()
+# Missing* is for NTIA 2021 conformance only
+
+
class MissingProperty(Strict):
# __match_args__ = ("property",)
# __match_args__: ClassVar[tuple[str, ...]] = cast("Any", ("property",))
@@ -203,6 +279,36 @@ Missing = Annotated[MissingProperty |
MissingComponentProperty, pydantic.Field(d
MissingAdapter = pydantic.TypeAdapter(Missing)
+# Outdated* is for any outdated tool
+
+
+class OutdatedTool(Strict):
+ kind: Literal["tool"] = "tool"
+ name: str
+ used_version: str
+ available_version: str
+
+
+class OutdatedMissingMetadata(Strict):
+ kind: Literal["missing_metadata"] = "missing_metadata"
+
+
+class OutdatedMissingTimestamp(Strict):
+ kind: Literal["missing_timestamp"] = "missing_timestamp"
+
+
+class OutdatedMissingVersion(Strict):
+ kind: Literal["missing_version"] = "missing_version"
+ name: str
+
+
+Outdated = Annotated[
+ OutdatedTool | OutdatedMissingMetadata | OutdatedMissingTimestamp |
OutdatedMissingVersion,
+ pydantic.Field(discriminator="kind"),
+]
+OutdatedAdapter = pydantic.TypeAdapter(Outdated)
+
+
@dataclasses.dataclass
class Bundle:
doc: yyjson.Document
@@ -418,22 +524,36 @@ def get_pointer(doc: yyjson.Document, path: str) -> Any |
None:
def main() -> None:
path = pathlib.Path(sys.argv[2])
bundle = path_to_bundle(path)
- patch_ops = bundle_to_patch(bundle)
match sys.argv[1]:
- case "patch":
- if patch_ops:
- patch_data = patch_to_data(patch_ops)
- print(yyjson.Document(patch_data).dumps())
- else:
- print("no patch needed")
case "merge":
+ patch_ops = bundle_to_patch(bundle)
if patch_ops:
patch_data = patch_to_data(patch_ops)
merged = bundle.doc.patch(yyjson.Document(patch_data))
print(merged.dumps())
else:
print(bundle.doc.dumps())
+ case "missing":
+ _warnings, errors = ntia_2021_conformance_issues(bundle.bom)
+ for error in errors:
+ print(error)
+ # for warning in warnings:
+ # print(warning)
+ case "outdated":
+ outdated = maven_plugin_outdated_version(bundle.bom)
+ if outdated:
+ print(outdated)
+ else:
+ print("no outdated tool found")
+ case "patch":
+ patch_ops = bundle_to_patch(bundle)
+ if patch_ops:
+ patch_data = patch_to_data(patch_ops)
+ print(yyjson.Document(patch_data).dumps())
+ else:
+ print("no patch needed")
case "scores":
+ patch_ops = bundle_to_patch(bundle)
if patch_ops:
patch_data = patch_to_data(patch_ops)
merged = bundle.doc.patch(yyjson.Document(patch_data))
@@ -442,12 +562,6 @@ def main() -> None:
print(sbomqs_total_score(bundle.doc))
case "validate":
print(bundle.doc.dumps())
- case "missing":
- _warnings, errors = ntia_2021_conformance_issues(bundle.bom)
- for error in errors:
- print(error)
- # for warning in warnings:
- # print(warning)
case "where":
_warnings, errors = ntia_2021_conformance_issues(bundle.bom)
for error in errors:
@@ -485,6 +599,76 @@ def maven_cache_write(cache: dict[str, Any]) -> None:
pass
+def maven_plugin_outdated_version(bom: Bom) -> Outdated | None:
+ # Need to search for the CycloneDX Maven Plugin
+ # metadata.tools.components[].name == "cyclonedx-maven-plugin"
+ # We check the version against when the SBOM was generated
+ # This is just a warning, of course
+ if bom.metadata is None:
+ return OutdatedMissingMetadata()
+ if bom.metadata.timestamp is None:
+ # This quite often isn't available
+ # We could use the file mtime, but that's extremely heuristic
+ return OutdatedMissingTimestamp()
+ tools = []
+ t = bom.metadata.tools
+ if isinstance(t, list):
+ tools = t
+ elif t:
+ tools = t.components or []
+ for tool in tools:
+ if tool.name != "cyclonedx-maven-plugin":
+ continue
+ if tool.version is None:
+ return OutdatedMissingVersion(name=tool.name)
+ available_version =
maven_plugin_outdated_version_core(bom.metadata.timestamp, tool.version)
+ if available_version is not None:
+ return OutdatedTool(
+ name=tool.name,
+ used_version=tool.version,
+ available_version=available_version,
+ )
+ return None
+
+
+def maven_plugin_outdated_version_core(isotime: str, version: str) -> str |
None:
+ expected_version = maven_version_as_of(isotime)
+ if expected_version is None:
+ return None
+ if version == expected_version:
+ return None
+ expected_version_comparable = maven_version_parse(expected_version)
+ version_comparable = maven_version_parse(version)
+ # If the version used is less than the version available
+ if version_comparable < expected_version_comparable:
+ # Then note the version available
+ return expected_version
+ # Otherwise, the user is using the latest version
+ return None
+
+
+def maven_version_as_of(isotime: str) -> str | None:
+ # Given these mappings:
+ # {
+ # t3: v3
+ # t2: v2
+ # t1: v1
+ # }
+ # If the input is after t3, then the output is v3
+ # If the input is between t2 and t1, then the output is v2
+ # If the input is between t1 and t2, then the output is v1
+ # If the input is before t1, then the output is None
+ for date, version in sorted(MAVEN_PLUGIN_VERSIONS.items(), reverse=True):
+ if isotime >= date:
+ return version
+ return None
+
+
+def maven_version_parse(version: str) -> tuple[int, int, int]:
+ parts = version.split(".")
+ return int(parts[0]), int(parts[1]), int(parts[2])
+
+
def ntia_2021_conformance_issues(bom: Bom) -> tuple[list[Missing],
list[Missing]]:
# 1. Supplier
# ECMA-424 1st edition says that this is the supplier of the primary
component
@@ -504,7 +688,7 @@ def ntia_2021_conformance_issues(bom: Bom) ->
tuple[list[Missing], list[Missing]
# NOTE: The CycloneDX guide is missing bom.metadata.component.cpe,purl,swid
# bom.components[].cpe,purl,swid
# NOTE: NTIA 2021 does not require unique identifiers
- # This is clear from NTIA 2025 draft adding this requirement
+ # This is clear from the CISA 2025 draft adding this requirement
# 5. Dependency Relationship
# bom.dependencies[]
diff --git a/scripts/github_tag_dates.py b/scripts/github_tag_dates.py
new file mode 100644
index 0000000..f2263c8
--- /dev/null
+++ b/scripts/github_tag_dates.py
@@ -0,0 +1,115 @@
+# 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 asyncio
+import json
+import os
+import sys
+
+import aiohttp
+
+URL = "https://api.github.com/graphql"
+Q = """
+query($owner:String!,$name:String!,$after:String){
+ repository(owner:$owner,name:$name){
+
refs(refPrefix:"refs/tags/",first:100,after:$after,orderBy:{field:TAG_COMMIT_DATE,direction:DESC}){
+ pageInfo{hasNextPage endCursor}
+ nodes{
+ name
+ target{
+ __typename oid
+ ... on Commit{committedDate}
+ ... on Tag{
+ target{
+ __typename oid
+ ... on Commit{committedDate}
+ }
+ }
+ }
+ }
+ }
+ }
+}
+"""
+
+
+def repo_from_arg(a: str) -> tuple[str, str]:
+ # Allow either owner/repo or owner repo
+ return (a.split("/", 1)[0], a.split("/", 1)[1]) if "/" in a else (a,
sys.argv[2])
+
+
+def pick(node: dict) -> tuple[str, str, str] | None:
+ # Pick the tag, commit, and committedDate from the node
+ t = node["target"]
+ if t["__typename"] == "Commit":
+ return node["name"], t["oid"], t["committedDate"]
+ if t["__typename"] == "Tag":
+ tt = t.get("target") or {}
+ if tt.get("__typename") == "Commit":
+ return node["name"], tt["oid"], tt["committedDate"]
+ return None
+
+
+async def page(s, aft, owner, name):
+ v = {"owner": owner, "name": name, "after": aft}
+ async with s.post(URL, json={"query": Q, "variables": v}) as r:
+ r.raise_for_status()
+ return await r.json()
+
+
+def hdr(tok: str) -> dict:
+ return {"Authorization": f"Bearer {tok}", "Accept": "application/json",
"User-Agent": "tags-min"}
+
+
+async def run(owner: str, name: str, tok: str):
+ out = {}
+ async with aiohttp.ClientSession(headers=hdr(tok)) as s:
+ aft = None
+ while True:
+ data = await page(s, aft, owner, name)
+ repo = data["data"]["repository"]
+ refs = repo["refs"]
+ for n in refs["nodes"]:
+ row = pick(n)
+ if row:
+ # Sometimes they use cyclonedx-maven-plugin-x.y.z, and
sometimes x.y.z
+ # The more consistent one is the former, so we filter out
the latter
+ if not row[0].startswith("cyclonedx-maven-plugin-"):
+ continue
+ # We discard the commit hash, which is row[1]
+ committed_date = row[2]
+ if committed_date in out:
+ raise SystemExit(f"duplicate committedDate:
{committed_date}")
+ version = row[0].removeprefix("cyclonedx-maven-plugin-")
+ out[committed_date] = version
+ if not refs["pageInfo"]["hasNextPage"]:
+ break
+ aft = refs["pageInfo"]["endCursor"]
+ print(json.dumps(out, ensure_ascii=False, indent=2))
+
+
+def main() -> None:
+ if not os.getenv("GITHUB_TOKEN"):
+ raise SystemExit("set GITHUB_TOKEN")
+ if len(sys.argv) < 2:
+ raise SystemExit("usage: github_tag_dates.py owner/repo")
+ owner, name = repo_from_arg(sys.argv[1])
+ asyncio.run(run(owner, name, os.environ["GITHUB_TOKEN"]))
+
+
+if __name__ == "__main__":
+ main()
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]