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-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 08717d7 Update outdated tool scanners and add ATR tool metadata to
the SBOM. Reference existing BOM versions in augment tasks.
08717d7 is described below
commit 08717d770e26c77e8a832572f749c018586d38e1
Author: Alastair McFarlane <[email protected]>
AuthorDate: Fri Dec 19 14:23:30 2025 +0000
Update outdated tool scanners and add ATR tool metadata to the SBOM.
Reference existing BOM versions in augment tasks.
---
.pre-commit-config.yaml | 4 +-
atr/get/sbom.py | 114 ++++++++++++++++++++++++++--------
atr/models/results.py | 11 +++-
atr/sbom/__init__.py | 3 +-
atr/sbom/cli.py | 2 +-
atr/sbom/maven.py | 59 +-----------------
atr/sbom/models/__init__.py | 14 +----
atr/sbom/models/bom.py | 10 +++
atr/sbom/models/{maven.py => tool.py} | 11 +++-
atr/sbom/osv.py | 1 -
atr/sbom/tool.py | 104 +++++++++++++++++++++++++++++++
atr/sbom/utilities.py | 103 ++++++++++++++++++++++++------
atr/tasks/sbom.py | 51 ++++++++-------
pyproject.toml | 1 +
uv.lock | 57 ++++++++++-------
15 files changed, 375 insertions(+), 170 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5539c1f..9b72a39 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -93,7 +93,7 @@ repos:
types_or: ['css']
args: ['--fix']
- repo: https://github.com/woodruffw/zizmor-pre-commit
- rev: v1.18.0
+ rev: v1.19.0
hooks:
- id: zizmor
args: [--min-severity, low]
@@ -102,7 +102,7 @@ repos:
hooks:
- id: pip-audit
- repo: https://github.com/oxc-project/mirrors-oxlint
- rev: v1.33.0
+ rev: v1.34.0
hooks:
- id: oxlint
name: lint JS files with Oxlint
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index 4a588e1..71c92fd 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -18,7 +18,7 @@
from __future__ import annotations
import json
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
import asfquart.base as base
import cmarkgfm
@@ -65,6 +65,16 @@ async def report(session: web.Committer, project: str,
version: str, file_path:
.order_by(sql.sqlmodel.desc(via(sql.Task.completed)))
.all()
)
+ augment_tasks = (
+ await data.task(
+ project_name=project,
+ version_name=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(
@@ -90,12 +100,18 @@ async def report(session: web.Committer, project: str,
version: str, file_path:
_report_header(block, is_release_candidate, release, task_result)
if not is_release_candidate:
- _augment_section(block, release, task_result)
+ 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]
+ last_augmented_bom = max(
+ [r.bom_version for r in augment_results if r is not None and
r.bom_version is not None]
+ )
+ _augment_section(block, release, task_result, latest_augment,
last_augmented_bom)
_conformance_section(block, task_result)
- block.h2["Vulnerabilities"]
-
if task_result.vulnerabilities is not None:
vulnerabilities = [
sbom.models.osv.CdxVulnAdapter.validate_python(json.loads(e)) for
e in task_result.vulnerabilities
@@ -107,31 +123,57 @@ async def report(session: web.Committer, project: str,
version: str, file_path:
block, project, version, file_path, task_result, vulnerabilities,
osv_tasks, is_release_candidate
)
- block.h2["Outdated tool"]
- outdated = None
- if task_result.outdated:
- outdated =
sbom.models.maven.OutdatedAdapter.validate_python(json.loads(task_result.outdated))
- if outdated:
- if outdated.kind == "tool":
- block.p[
- f"""The CycloneDX Maven Plugin is outdated. The used version is
- {outdated.used_version} and the available version is
- {outdated.available_version}."""
- ]
- else:
- block.p[
- f"""There was a problem with the SBOM detected when trying to
- determine if the CycloneDX Maven Plugin is outdated:
- {outdated.kind.upper()}."""
- ]
- else:
- block.p["No outdated tool found."]
+ _outdated_tool_section(block, task_result)
_cyclonedx_cli_errors(block, task_result)
return await template.blank("SBOM report", content=block.collect())
+def _outdated_tool_section(block: htm.Block, task_result:
results.SBOMToolScore):
+ block.h2["Outdated tools"]
+ if task_result.outdated:
+ outdated = []
+ if isinstance(task_result.outdated, str):
+ # Older version, only checked one tool
+ outdated =
[sbom.models.tool.OutdatedAdapter.validate_python(json.loads(task_result.outdated))]
+ elif isinstance(task_result.outdated, list):
+ # Newer version, checked multiple tools
+ outdated =
[sbom.models.tool.OutdatedAdapter.validate_python(json.loads(o)) for o in
task_result.outdated]
+ if len(outdated) == 0:
+ block.p["No outdated tools found."]
+ for result in outdated:
+ if result.kind == "tool":
+ if "Apache Trusted Releases" in result.name:
+ block.p[
+ f"""The last version of ATR used on this SBOM was
+ {result.used_version} but ATR is currently version
+ {result.available_version}."""
+ ]
+ else:
+ block.p[
+ f"""The {result.name} is outdated. The used version is
+ {result.used_version} and the available version is
+ {result.available_version}."""
+ ]
+ else:
+ if result.kind == "missing_metadata" or result.kind ==
"missing_timestamp":
+ # These both return without checking any further tools as
they prevent checking
+ block.p[
+ f"""There was a problem with the SBOM detected when
trying to
+ determine if any tools were outdated:
+ {result.kind.upper()}."""
+ ]
+ else:
+ block.p[
+ f"""There was a problem with the SBOM detected when
trying to
+ determine if the {result.name} is outdated:
+ {result.kind.upper()}."""
+ ]
+ else:
+ block.p["No outdated tools found."]
+
+
def _conformance_section(block: htm.Block, task_result: results.SBOMToolScore)
-> None:
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]
@@ -180,12 +222,28 @@ async def _report_task_results(block: htm.Block, tasks:
list[sql.Task]):
return await template.blank("SBOM report", content=block.collect())
-def _augment_section(block: htm.Block, release: sql.Release, task_result:
results.SBOMToolScore):
- # TODO: Show the status if the task to augment the SBOM is still running
- # And then don't allow it to be augmented again
+def _augment_section(
+ block: htm.Block,
+ release: sql.Release,
+ task_result: results.SBOMToolScore,
+ latest_task: sql.Task | None,
+ last_bom: int | None,
+):
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:
block.p["We can attempt to augment this SBOM with additional data."]
form.render_block(
@@ -195,6 +253,8 @@ def _augment_section(block: htm.Block, release:
sql.Release, task_result: result
empty=True,
)
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:
@@ -432,6 +492,8 @@ def _vulnerability_scan_section(
in_progress_task = _vulnerability_scan_find_in_progress_task(osv_tasks,
task_result.revision_number)
+ block.h2["Vulnerabilities"]
+
scans = []
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"]
diff --git a/atr/models/results.py b/atr/models/results.py
index 166a4ed..123324f 100644
--- a/atr/models/results.py
+++ b/atr/models/results.py
@@ -58,6 +58,9 @@ class SBOMOSVScan(schema.Strict):
project_name: str = schema.description("Project name")
version_name: str = schema.description("Version name")
revision_number: str = schema.description("Revision number")
+ bom_version: int | None = schema.Field(
+ default=None, strict=False, description="BOM Version produced with
scan results"
+ )
file_path: str = schema.description("Relative path to the scanned SBOM
file")
new_file_path: str = schema.Field(default="", strict=False,
description="Relative path to the updated SBOM file")
components: list[OSVComponent] = schema.description("Components with
vulnerabilities")
@@ -103,6 +106,11 @@ class SbomQsReport(schema.Strict):
class SBOMAugment(schema.Strict):
kind: Literal["sbom_augment"] = schema.Field(alias="kind")
path: str = schema.description("The path to the augmented SBOM file")
+ bom_version: int | None = schema.Field(
+ default=None,
+ strict=False,
+ description="BOM Version produced by the augment task, if any
augmentations were applied",
+ )
class SBOMQsScore(schema.Strict):
@@ -119,10 +127,11 @@ class SBOMToolScore(schema.Strict):
project_name: str = schema.description("Project name")
version_name: str = schema.description("Version name")
revision_number: str = schema.description("Revision number")
+ bom_version: int | None = schema.Field(default=None, strict=False,
description="BOM Version scanned")
file_path: str = schema.description("Relative path to the scored SBOM
file")
warnings: list[str] = schema.description("Warnings from the SBOM tool")
errors: list[str] = schema.description("Errors from the SBOM tool")
- outdated: str | None = schema.description("Outdated tool from the SBOM
tool")
+ outdated: list[str] | str | None = schema.description("Outdated tool(s)
from the SBOM tool")
vulnerabilities: list[str] | None = schema.Field(
default=None, strict=False, description="Vulnerabilities found in the
SBOM"
)
diff --git a/atr/sbom/__init__.py b/atr/sbom/__init__.py
index b560995..595ce27 100644
--- a/atr/sbom/__init__.py
+++ b/atr/sbom/__init__.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-from . import cli, conformance, constants, cyclonedx, licenses, maven, models,
osv, sbomqs, spdx, utilities
+from . import cli, conformance, constants, cyclonedx, licenses, maven, models,
osv, sbomqs, spdx, tool, utilities
__all__ = [
"cli",
@@ -28,5 +28,6 @@ __all__ = [
"osv",
"sbomqs",
"spdx",
+ "tool",
"utilities",
]
diff --git a/atr/sbom/cli.py b/atr/sbom/cli.py
index edc93fd..74647a4 100644
--- a/atr/sbom/cli.py
+++ b/atr/sbom/cli.py
@@ -25,8 +25,8 @@ from . import models, osv
from .conformance import ntia_2021_issues
from .cyclonedx import validate_cli, validate_py
from .licenses import check
-from .maven import plugin_outdated_version
from .sbomqs import total_score
+from .tool import plugin_outdated_version
from .utilities import bundle_to_ntia_patch, bundle_to_vuln_patch,
patch_to_data, path_to_bundle
diff --git a/atr/sbom/maven.py b/atr/sbom/maven.py
index a443414..5ff9bc5 100644
--- a/atr/sbom/maven.py
+++ b/atr/sbom/maven.py
@@ -17,14 +17,13 @@
from __future__ import annotations
-import datetime
import pathlib
import tempfile
from typing import Any, Final
import yyjson
-from . import constants, models
+from . import constants
_CACHE_PATH: Final[pathlib.Path] = pathlib.Path(tempfile.gettempdir()) /
"sbomtool-cache.json"
@@ -49,57 +48,6 @@ def cache_write(cache: dict[str, Any]) -> None:
pass
-def plugin_outdated_version(bom_value: models.bom.Bom) ->
models.maven.Outdated | None:
- if bom_value.metadata is None:
- return models.maven.OutdatedMissingMetadata()
- timestamp = bom_value.metadata.timestamp
- if timestamp is None:
- # This quite often isn't available
- # We could use the file mtime, but that's extremely heuristic
- # return OutdatedMissingTimestamp()
- timestamp =
datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
- tools = []
- tools_value = bom_value.metadata.tools
- if isinstance(tools_value, list):
- tools = tools_value
- elif tools_value:
- tools = tools_value.components or []
- for tool in tools:
- names_or_descriptions = {
- "cyclonedx maven plugin",
- "cyclonedx-maven-plugin",
- }
- name_or_description = (tool.name or tool.description or "").lower()
- if name_or_description not in names_or_descriptions:
- continue
- if tool.version is None:
- return
models.maven.OutdatedMissingVersion(name=name_or_description)
- available_version = plugin_outdated_version_core(timestamp,
tool.version)
- if available_version is not None:
- return models.maven.OutdatedTool(
- name=name_or_description,
- used_version=tool.version,
- available_version=available_version,
- )
- return None
-
-
-def plugin_outdated_version_core(isotime: str, version: str) -> str | None:
- expected_version = version_as_of(isotime)
- if expected_version is None:
- return None
- if version == expected_version:
- return None
- expected_version_comparable = version_parse(expected_version)
- version_comparable = 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 version_as_of(isotime: str) -> str | None:
# Given these mappings:
# {
@@ -115,8 +63,3 @@ def version_as_of(isotime: str) -> str | None:
if isotime >= date:
return version
return None
-
-
-def version_parse(version: str) -> tuple[int, int, int]:
- parts = version.split(".")
- return int(parts[0]), int(parts[1]), int(parts[2])
diff --git a/atr/sbom/models/__init__.py b/atr/sbom/models/__init__.py
index fd44fb8..15cf734 100644
--- a/atr/sbom/models/__init__.py
+++ b/atr/sbom/models/__init__.py
@@ -17,16 +17,6 @@
from __future__ import annotations
-from . import base, bom, bundle, conformance, licenses, maven, osv, patch,
sbomqs
+from . import base, bom, bundle, conformance, licenses, osv, patch, sbomqs,
tool
-__all__ = [
- "base",
- "bom",
- "bundle",
- "conformance",
- "licenses",
- "maven",
- "osv",
- "patch",
- "sbomqs",
-]
+__all__ = ["base", "bom", "bundle", "conformance", "licenses", "osv", "patch",
"sbomqs", "tool"]
diff --git a/atr/sbom/models/bom.py b/atr/sbom/models/bom.py
index 3bc65f9..e02dff2 100644
--- a/atr/sbom/models/bom.py
+++ b/atr/sbom/models/bom.py
@@ -59,6 +59,15 @@ class ToolComponent(Lax):
name: str | None = None
version: str | None = None
description: str | None = None
+ supplier: Supplier | None = None
+
+
+class ServiceComponent(Lax):
+ name: str | None = None
+ version: str | None = None
+ description: str | None = None
+ supplier: Supplier | None = None
+ authenticated: bool | None = None
class Tool(Lax):
@@ -69,6 +78,7 @@ class Tool(Lax):
class Tools(Lax):
components: list[ToolComponent] | None = None
+ services: list[ServiceComponent] | None = None
class Metadata(Lax):
diff --git a/atr/sbom/models/maven.py b/atr/sbom/models/tool.py
similarity index 86%
rename from atr/sbom/models/maven.py
rename to atr/sbom/models/tool.py
index 8f87551..1e23fa9 100644
--- a/atr/sbom/models/maven.py
+++ b/atr/sbom/models/tool.py
@@ -17,12 +17,15 @@
from __future__ import annotations
-from typing import Annotated, Literal
+from typing import TYPE_CHECKING, Annotated, Literal, NamedTuple
import pydantic
from .base import Strict
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
class OutdatedTool(Strict):
kind: Literal["tool"] = "tool"
@@ -44,6 +47,12 @@ class OutdatedMissingVersion(Strict):
name: str
+class Tool(NamedTuple):
+ key: str
+ friendly_name: str
+ version_function: Callable[[str], str | None]
+
+
type Outdated = Annotated[
OutdatedTool | OutdatedMissingMetadata | OutdatedMissingTimestamp |
OutdatedMissingVersion,
pydantic.Field(discriminator="kind"),
diff --git a/atr/sbom/osv.py b/atr/sbom/osv.py
index 7008ef9..01609ba 100644
--- a/atr/sbom/osv.py
+++ b/atr/sbom/osv.py
@@ -108,7 +108,6 @@ def vulns_from_bundle(bundle: models.bundle.Bundle) ->
list[models.osv.CdxVulner
async def vuln_patch(
- session: aiohttp.ClientSession,
doc: yyjson.Document,
components: list[models.osv.ComponentVulnerabilities],
) -> models.patch.Patch:
diff --git a/atr/sbom/tool.py b/atr/sbom/tool.py
new file mode 100644
index 0000000..febf733
--- /dev/null
+++ b/atr/sbom/tool.py
@@ -0,0 +1,104 @@
+# 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
+
+import datetime
+from typing import TYPE_CHECKING, Any, Final
+
+import semver
+
+from . import maven, models, utilities
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+_KNOWN_TOOLS: Final[dict[str, models.tool.Tool]] = {
+ # name in file: ( canonical name, friendly name, version callable )
+ "cyclonedx-maven-plugin": models.tool.Tool("cyclonedx-maven-plugin",
"CycloneDX Maven Plugin", maven.version_as_of),
+ "cyclonedx maven plugin": models.tool.Tool("cyclonedx-maven-plugin",
"CycloneDX Maven Plugin", maven.version_as_of),
+ "apache trusted releases": models.tool.Tool(
+ "apache trusted releases", "Apache Trusted Releases platform", lambda
_: utilities.get_atr_version()
+ ),
+}
+
+
+def plugin_outdated_version(bom_value: models.bom.Bom) ->
list[models.tool.Outdated] | None:
+ if bom_value.metadata is None:
+ return [models.tool.OutdatedMissingMetadata()]
+ timestamp = bom_value.metadata.timestamp
+ if timestamp is None:
+ # This quite often isn't available
+ # We could use the file mtime, but that's extremely heuristic
+ # return OutdatedMissingTimestamp()
+ timestamp =
datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+ tools: list[Any] = []
+ tools_value = bom_value.metadata.tools
+ if isinstance(tools_value, list):
+ tools = tools_value
+ elif tools_value:
+ tools = tools_value.components or []
+ services = tools_value.services or []
+ tools.extend(services)
+ errors = []
+ for tool in tools:
+ name_or_description = (tool.name or tool.description or "").lower()
+ if name_or_description not in _KNOWN_TOOLS:
+ continue
+ if tool.version is None:
+
errors.append(models.tool.OutdatedMissingVersion(name=name_or_description))
+ continue
+ tool_data = _KNOWN_TOOLS[name_or_description]
+ available_version = outdated_version_core(timestamp, tool.version,
tool_data.version_function)
+ if available_version is not None:
+ errors.append(
+ models.tool.OutdatedTool(
+ name=tool_data.friendly_name,
+ used_version=tool.version,
+ available_version=str(available_version),
+ )
+ )
+ return errors
+
+
+def outdated_version_core(
+ isotime: str, version: str, version_as_of: Callable[[str], str | None]
+) -> semver.VersionInfo | None:
+ expected_version = version_as_of(isotime)
+ if expected_version is None:
+ return None
+ if version == expected_version:
+ return None
+ expected_version_comparable = version_parse(expected_version)
+ version_comparable = version_parse(version)
+ if expected_version_comparable is None or version_comparable is None:
+ # Couldn't parse the version
+ return None
+ # If the version used is less than the version available
+ if version_comparable < expected_version_comparable:
+ # Then note the version available
+ return expected_version_comparable
+ # Otherwise, the user is using the latest version
+ return None
+
+
+def version_parse(version_str: str) -> semver.VersionInfo | None:
+ try:
+ return semver.VersionInfo.parse(version_str.lstrip("v"))
+ except ValueError:
+ return None
diff --git a/atr/sbom/utilities.py b/atr/sbom/utilities.py
index 36a2cf5..fa91930 100644
--- a/atr/sbom/utilities.py
+++ b/atr/sbom/utilities.py
@@ -30,11 +30,44 @@ if TYPE_CHECKING:
import aiohttp
import yyjson
-from . import models
+from . import constants, models
+
+
+def get_atr_version():
+ try:
+ from atr import metadata
+
+ return metadata.version
+ except ImportError:
+ return "cli"
+
+
+_ATR_VERSION = get_atr_version()
_SCORING_METHODS_OSV = {"CVSS_V2": "CVSSv2", "CVSS_V3": "CVSSv3", "CVSS_V4":
"CVSSv4"}
_SCORING_METHODS_CDX = {"CVSSv2": "CVSS_V2", "CVSSv3": "CVSS_V3", "CVSSv4":
"CVSS_V4", "other": "Other"}
_CDX_SEVERITIES = ["critical", "high", "medium", "low", "info", "none",
"unknown"]
+_TOOL_METADATA = {
+ "bom-ref": "tool:asf:atr",
+ "provider": {
+ "name": constants.conformance.THE_APACHE_SOFTWARE_FOUNDATION,
+ "url": ["https://apache.org/"],
+ },
+ "name": "Apache Trusted Releases",
+ "authenticated": True,
+ "version": _ATR_VERSION,
+}
+
+
+def apply_patch(
+ reason: str, revision: str, bundle: models.bundle.Bundle, patch_ops:
models.patch.Patch
+) -> tuple[int, yyjson.Document]:
+ """Take a list of patch operations and apply them to the bundle. Returns
the patched document, with the
+ task recorded in `properties` and the SBOM version number incremented as
per the CDX spec."""
+ _record_task(reason, revision, bundle.doc, patch_ops)
+ new_version = _increment_version(bundle.doc, patch_ops)
+ patch_data = patch_to_data(patch_ops)
+ return new_version, bundle.doc.patch(yyjson.Document(patch_data))
async def bundle_to_ntia_patch(bundle_value: models.bundle.Bundle) ->
models.patch.Patch:
@@ -51,9 +84,7 @@ async def bundle_to_vuln_patch(
) -> models.patch.Patch:
from .osv import vuln_patch
- # TODO: May not need session (copied from ntia patch)
- async with aiohttp.ClientSession() as session:
- patch_ops = await vuln_patch(session, bundle_value.doc,
vulnerabilities)
+ patch_ops = await vuln_patch(bundle_value.doc, vulnerabilities)
return patch_ops
@@ -67,11 +98,14 @@ def get_pointer(doc: yyjson.Document, path: str) -> Any |
None:
raise
-def get_atr_props_from_bundle(bundle_value: models.bundle.Bundle) ->
list[dict[str, str]]:
+def get_props_from_bundle(bundle_value: models.bundle.Bundle) -> tuple[int,
list[dict[str, str]]]:
+ version: int | None = get_pointer(bundle_value.doc, "/version")
+ if version is None:
+ version = 0
properties: list[dict[str, str]] | None = get_pointer(bundle_value.doc,
"/properties")
if properties is None:
- return []
- return [p for p in properties if "asf:atr:" in p.get("name", "")]
+ return version, []
+ return version, [p for p in properties if "asf:atr:" in p.get("name", "")]
def patch_to_data(patch_ops: models.patch.Patch) -> list[dict[str, Any]]:
@@ -84,17 +118,6 @@ def path_to_bundle(path: pathlib.Path) ->
models.bundle.Bundle:
return models.bundle.Bundle(doc=yyjson.Document(text), bom=bom, path=path,
text=text)
-def record_task(task: str, revision: str, doc: yyjson.Document, patch_ops:
models.patch.Patch) -> models.patch.Patch:
- properties: list[dict[str, str]] | None = get_pointer(doc, "/properties")
- operation = {"name": f"asf:atr:{task}", "value": revision}
- if properties is None:
- patch_ops.append(models.patch.AddOp(op="add", path="/properties",
value=[operation]))
- else:
- properties.append(operation)
- patch_ops.append(models.patch.ReplaceOp(op="replace",
path="/properties", value=properties))
- return patch_ops
-
-
def osv_severity_to_cdx(severity: list[dict[str, Any]] | None, textual: str)
-> list[dict[str, str | float]] | None:
if severity is not None:
return [
@@ -155,6 +178,18 @@ def _extract_cdx_score(type: str, score_str: str) ->
dict[str, str | float]:
return {"severity": score_str}
+def _increment_version(doc: yyjson.Document, patch_ops: models.patch.Patch) ->
int:
+ version: int | None = get_pointer(doc, "/version")
+ if version is not None:
+ version = version + 1
+ patch_ops.append(models.patch.ReplaceOp(op="replace", path="/version",
value=version))
+ else:
+ # This shouldn't happen, but we can handle it just in case
+ version = 1
+ patch_ops.append(models.patch.AddOp(op="add", path="/version",
value=version))
+ return version
+
+
def _map_severity(severity: str) -> str:
sev = severity.lower()
if sev in _CDX_SEVERITIES:
@@ -164,3 +199,35 @@ def _map_severity(severity: str) -> str:
if sev == "moderate":
return "medium"
return "unknown"
+
+
+def _record_task(task: str, revision: str, doc: yyjson.Document, patch_ops:
models.patch.Patch) -> models.patch.Patch:
+ properties: list[dict[str, str]] | None = get_pointer(doc, "/properties")
+ tools: dict[str, Any] | None = get_pointer(doc, "/metadata/tools")
+ services: list[dict[str, Any]] | None = get_pointer(doc,
"/metadata/tools/services")
+ operation = {"name": f"asf:atr:{task}", "value": revision}
+ if properties is None:
+ patch_ops.append(models.patch.AddOp(op="add", path="/properties",
value=[operation]))
+ else:
+ properties.append(operation)
+ patch_ops.append(models.patch.ReplaceOp(op="replace",
path="/properties", value=properties))
+ if tools is None:
+ tools = {}
+ patch_ops.append(models.patch.AddOp(op="add", path="/metadata/tools",
value=tools))
+ if services is None:
+ services = []
+ patch_ops.append(models.patch.AddOp(op="add",
path="/metadata/tools/services", value=services))
+ try:
+ tool_index = [s.get("bom-ref", "") for s in
services].index("tool:asf:atr")
+ except ValueError:
+ tool_index = -1
+ if tool_index == -1:
+ patch_ops.append(
+ models.patch.AddOp(op="add",
path=f"/metadata/tools/services/{len(services)}", value=_TOOL_METADATA)
+ )
+ else:
+ # If we ever add more metadata to this, such as task-specific
properties under the tool, this changes
+ patch_ops.append(
+ models.patch.ReplaceOp(op="replace",
path=f"/metadata/tools/services/{tool_index}", value=_TOOL_METADATA)
+ )
+ return patch_ops
diff --git a/atr/tasks/sbom.py b/atr/tasks/sbom.py
index d07dd0d..506939b 100644
--- a/atr/tasks/sbom.py
+++ b/atr/tasks/sbom.py
@@ -23,7 +23,6 @@ from typing import Any, Final
import aiofiles
import aiofiles.os
-import yyjson
import atr.archives as archives
import atr.config as config
@@ -87,10 +86,9 @@ async def augment(args: FileArgs) -> results.Results | None:
bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
patch_ops = await sbom.utilities.bundle_to_ntia_patch(bundle)
new_full_path: str | None = None
+ new_version = None
if patch_ops:
- sbom.utilities.record_task("augment", args.revision_number,
bundle.doc, patch_ops)
- patch_data = sbom.utilities.patch_to_data(patch_ops)
- merged = bundle.doc.patch(yyjson.Document(patch_data))
+ new_version, merged = sbom.utilities.apply_patch("augment",
args.revision_number, bundle, patch_ops)
description = "SBOM augmentation through web interface"
async with storage.write(args.asf_uid) as write:
wacp = await
write.as_project_committee_participant(args.project_name)
@@ -110,6 +108,7 @@ async def augment(args: FileArgs) -> results.Results | None:
return results.SBOMAugment(
kind="sbom_augment",
path=(new_full_path if new_full_path is not None else full_path),
+ bom_version=new_version,
)
@@ -145,31 +144,29 @@ async def osv_scan(args: FileArgs) -> results.Results |
None:
components = [results.OSVComponent(purl=v.ref,
vulnerabilities=v.vulnerabilities) for v in vulnerabilities]
new_full_path: str | None = None
- if patch_ops:
- sbom.utilities.record_task("osv-scan", args.revision_number,
bundle.doc, patch_ops)
- patch_data = sbom.utilities.patch_to_data(patch_ops)
- merged = bundle.doc.patch(yyjson.Document(patch_data))
- description = "SBOM vulnerability scan through web interface"
- async with storage.write(args.asf_uid) as write:
- wacp = await
write.as_project_committee_participant(args.project_name)
- async with wacp.revision.create_and_manage(
- args.project_name, args.version_name, args.asf_uid or
"unknown", description=description
- ) as creating:
- new_full_path = os.path.join(str(creating.interim_path),
args.file_path)
- # Write to the new revision
- log.info(f"Writing updated SBOM to {new_full_path}")
- await aiofiles.os.remove(new_full_path)
- async with aiofiles.open(new_full_path, "w", encoding="utf-8")
as f:
- await f.write(merged.dumps())
-
- if creating.new is None:
- raise RuntimeError("Internal error: New revision not found")
+ new_version, merged = sbom.utilities.apply_patch("osv-scan",
args.revision_number, bundle, patch_ops)
+ description = "SBOM vulnerability scan through web interface"
+ async with storage.write(args.asf_uid) as write:
+ wacp = await write.as_project_committee_participant(args.project_name)
+ async with wacp.revision.create_and_manage(
+ args.project_name, args.version_name, args.asf_uid or "unknown",
description=description
+ ) as creating:
+ new_full_path = os.path.join(str(creating.interim_path),
args.file_path)
+ # Write to the new revision
+ log.info(f"Writing updated SBOM to {new_full_path}")
+ await aiofiles.os.remove(new_full_path)
+ async with aiofiles.open(new_full_path, "w", encoding="utf-8") as
f:
+ await f.write(merged.dumps())
+
+ if creating.new is None:
+ raise RuntimeError("Internal error: New revision not found")
return results.SBOMOSVScan(
kind="sbom_osv_scan",
project_name=args.project_name,
version_name=args.version_name,
revision_number=args.revision_number,
+ bom_version=new_version,
file_path=full_path,
new_file_path=new_full_path or full_path,
components=components,
@@ -221,9 +218,10 @@ async def score_tool(args: FileArgs) -> results.Results |
None:
if not (full_path.endswith(".cdx.json") and os.path.isfile(full_path)):
raise SBOMScoringError("SBOM file does not exist", {"file_path":
args.file_path})
bundle = sbom.utilities.path_to_bundle(pathlib.Path(full_path))
- properties = sbom.utilities.get_atr_props_from_bundle(bundle)
+ version, properties = sbom.utilities.get_props_from_bundle(bundle)
warnings, errors = sbom.conformance.ntia_2021_issues(bundle.bom)
- outdated = sbom.maven.plugin_outdated_version(bundle.bom)
+ # TODO: Could update the ATR version with a constant showing last change
to the augment/scan tools
+ outdated = sbom.tool.plugin_outdated_version(bundle.bom)
vulnerabilities = sbom.osv.vulns_from_bundle(bundle)
cli_errors = sbom.cyclonedx.validate_cli(bundle)
return results.SBOMToolScore(
@@ -231,10 +229,11 @@ async def score_tool(args: FileArgs) -> results.Results |
None:
project_name=args.project_name,
version_name=args.version_name,
revision_number=args.revision_number,
+ bom_version=version,
file_path=args.file_path,
warnings=[w.model_dump_json() for w in warnings],
errors=[e.model_dump_json() for e in errors],
- outdated=outdated.model_dump_json() if outdated else None,
+ outdated=[o.model_dump_json() for o in outdated] if outdated else None,
vulnerabilities=[v.model_dump_json() for v in vulnerabilities],
atr_props=properties,
cli_errors=cli_errors,
diff --git a/pyproject.toml b/pyproject.toml
index 6c41378..a1263df 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -43,6 +43,7 @@ dependencies = [
"quart-schema[pydantic]~=0.21",
"quart-wtforms~=1.0.3",
"rich~=14.0.0",
+ "semver>=3.0.4",
"sqlmodel~=0.0.24",
"standard-imghdr>=3.13.0",
"yyjson>=4.0.6",
diff --git a/uv.lock b/uv.lock
index be40bd1..8b73407 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
requires-python = "==3.13.*"
[options]
-exclude-newer = "2025-12-17T17:16:16Z"
+exclude-newer = "2025-12-19T17:04:05Z"
[[package]]
name = "aiofiles"
@@ -1661,28 +1661,37 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.14.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url =
"https://files.pythonhosted.org/packages/f6/1b/ab712a9d5044435be8e9a2beb17cbfa4c241aa9b5e4413febac2a8b79ef2/ruff-0.14.9.tar.gz",
hash =
"sha256:35f85b25dd586381c0cc053f48826109384c81c00ad7ef1bd977bfcc28119d5b", size
= 5809165, upload-time = "2025-12-11T21:39:47.381Z" }
-wheels = [
- { url =
"https://files.pythonhosted.org/packages/b8/1c/d1b1bba22cffec02351c78ab9ed4f7d7391876e12720298448b29b7229c1/ruff-0.14.9-py3-none-linux_armv6l.whl",
hash =
"sha256:f1ec5de1ce150ca6e43691f4a9ef5c04574ad9ca35c8b3b0e18877314aba7e75", size
= 13576541, upload-time = "2025-12-11T21:39:14.806Z" },
- { url =
"https://files.pythonhosted.org/packages/94/ab/ffe580e6ea1fca67f6337b0af59fc7e683344a43642d2d55d251ff83ceae/ruff-0.14.9-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:ed9d7417a299fc6030b4f26333bf1117ed82a61ea91238558c0268c14e00d0c2", size
= 13779363, upload-time = "2025-12-11T21:39:20.29Z" },
- { url =
"https://files.pythonhosted.org/packages/7d/f8/2be49047f929d6965401855461e697ab185e1a6a683d914c5c19c7962d9e/ruff-0.14.9-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:d5dc3473c3f0e4a1008d0ef1d75cee24a48e254c8bed3a7afdd2b4392657ed2c", size
= 12925292, upload-time = "2025-12-11T21:39:38.757Z" },
- { url =
"https://files.pythonhosted.org/packages/9e/e9/08840ff5127916bb989c86f18924fd568938b06f58b60e206176f327c0fe/ruff-0.14.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:84bf7c698fc8f3cb8278830fb6b5a47f9bcc1ed8cb4f689b9dd02698fa840697", size
= 13362894, upload-time = "2025-12-11T21:39:02.524Z" },
- { url =
"https://files.pythonhosted.org/packages/31/1c/5b4e8e7750613ef43390bb58658eaf1d862c0cc3352d139cd718a2cea164/ruff-0.14.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:aa733093d1f9d88a5d98988d8834ef5d6f9828d03743bf5e338bf980a19fce27", size
= 13311482, upload-time = "2025-12-11T21:39:17.51Z" },
- { url =
"https://files.pythonhosted.org/packages/5b/3a/459dce7a8cb35ba1ea3e9c88f19077667a7977234f3b5ab197fad240b404/ruff-0.14.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:6a1cfb04eda979b20c8c19550c8b5f498df64ff8da151283311ce3199e8b3648", size
= 14016100, upload-time = "2025-12-11T21:39:41.948Z" },
- { url =
"https://files.pythonhosted.org/packages/a6/31/f064f4ec32524f9956a0890fc6a944e5cf06c63c554e39957d208c0ffc45/ruff-0.14.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl",
hash =
"sha256:1e5cb521e5ccf0008bd74d5595a4580313844a42b9103b7388eca5a12c970743", size
= 15477729, upload-time = "2025-12-11T21:39:23.279Z" },
- { url =
"https://files.pythonhosted.org/packages/7a/6d/f364252aad36ccd443494bc5f02e41bf677f964b58902a17c0b16c53d890/ruff-0.14.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:cd429a8926be6bba4befa8cdcf3f4dd2591c413ea5066b1e99155ed245ae42bb", size
= 15122386, upload-time = "2025-12-11T21:39:33.125Z" },
- { url =
"https://files.pythonhosted.org/packages/20/02/e848787912d16209aba2799a4d5a1775660b6a3d0ab3944a4ccc13e64a02/ruff-0.14.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:ab208c1b7a492e37caeaf290b1378148f75e13c2225af5d44628b95fd7834273", size
= 14497124, upload-time = "2025-12-11T21:38:59.33Z" },
- { url =
"https://files.pythonhosted.org/packages/f3/51/0489a6a5595b7760b5dbac0dd82852b510326e7d88d51dbffcd2e07e3ff3/ruff-0.14.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:72034534e5b11e8a593f517b2f2f2b273eb68a30978c6a2d40473ad0aaa4cb4a", size
= 14195343, upload-time = "2025-12-11T21:39:44.866Z" },
- { url =
"https://files.pythonhosted.org/packages/f6/53/3bb8d2fa73e4c2f80acc65213ee0830fa0c49c6479313f7a68a00f39e208/ruff-0.14.9-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:712ff04f44663f1b90a1195f51525836e3413c8a773574a7b7775554269c30ed", size
= 14346425, upload-time = "2025-12-11T21:39:05.927Z" },
- { url =
"https://files.pythonhosted.org/packages/ad/04/bdb1d0ab876372da3e983896481760867fc84f969c5c09d428e8f01b557f/ruff-0.14.9-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:a111fee1db6f1d5d5810245295527cda1d367c5aa8f42e0fca9a78ede9b4498b", size
= 13258768, upload-time = "2025-12-11T21:39:08.691Z" },
- { url =
"https://files.pythonhosted.org/packages/40/d9/8bf8e1e41a311afd2abc8ad12be1b6c6c8b925506d9069b67bb5e9a04af3/ruff-0.14.9-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:8769efc71558fecc25eb295ddec7d1030d41a51e9dcf127cbd63ec517f22d567", size
= 13326939, upload-time = "2025-12-11T21:39:53.842Z" },
- { url =
"https://files.pythonhosted.org/packages/f4/56/a213fa9edb6dd849f1cfbc236206ead10913693c72a67fb7ddc1833bf95d/ruff-0.14.9-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:347e3bf16197e8a2de17940cd75fd6491e25c0aa7edf7d61aa03f146a1aa885a", size
= 13578888, upload-time = "2025-12-11T21:39:35.988Z" },
- { url =
"https://files.pythonhosted.org/packages/33/09/6a4a67ffa4abae6bf44c972a4521337ffce9cbc7808faadede754ef7a79c/ruff-0.14.9-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:7715d14e5bccf5b660f54516558aa94781d3eb0838f8e706fb60e3ff6eff03a8", size
= 14314473, upload-time = "2025-12-11T21:39:50.78Z" },
- { url =
"https://files.pythonhosted.org/packages/12/0d/15cc82da5d83f27a3c6b04f3a232d61bc8c50d38a6cd8da79228e5f8b8d6/ruff-0.14.9-py3-none-win32.whl",
hash =
"sha256:df0937f30aaabe83da172adaf8937003ff28172f59ca9f17883b4213783df197", size
= 13202651, upload-time = "2025-12-11T21:39:26.628Z" },
- { url =
"https://files.pythonhosted.org/packages/32/f7/c78b060388eefe0304d9d42e68fab8cffd049128ec466456cef9b8d4f06f/ruff-0.14.9-py3-none-win_amd64.whl",
hash =
"sha256:c0b53a10e61df15a42ed711ec0bda0c582039cf6c754c49c020084c55b5b0bc2", size
= 14702079, upload-time = "2025-12-11T21:39:11.954Z" },
- { url =
"https://files.pythonhosted.org/packages/26/09/7a9520315decd2334afa65ed258fed438f070e31f05a2e43dd480a5e5911/ruff-0.14.9-py3-none-win_arm64.whl",
hash =
"sha256:8e821c366517a074046d92f0e9213ed1c13dbc5b37a7fc20b07f79b64d62cc84", size
= 13744730, upload-time = "2025-12-11T21:39:29.659Z" },
+version = "0.14.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz",
hash =
"sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size
= 5859763, upload-time = "2025-12-18T19:28:57.98Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl",
hash =
"sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size
= 13527080, upload-time = "2025-12-18T19:29:25.642Z" },
+ { url =
"https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl",
hash =
"sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size
= 13797320, upload-time = "2025-12-18T19:29:02.571Z" },
+ { url =
"https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl",
hash =
"sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size
= 12918434, upload-time = "2025-12-18T19:28:51.202Z" },
+ { url =
"https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl",
hash =
"sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size
= 13371961, upload-time = "2025-12-18T19:29:04.991Z" },
+ { url =
"https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl",
hash =
"sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size
= 13275629, upload-time = "2025-12-18T19:29:21.381Z" },
+ { url =
"https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl",
hash =
"sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size
= 14029234, upload-time = "2025-12-18T19:29:00.132Z" },
+ { url =
"https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl",
hash =
"sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size
= 15449890, upload-time = "2025-12-18T19:28:53.573Z" },
+ { url =
"https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl",
hash =
"sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size
= 15123172, upload-time = "2025-12-18T19:29:23.453Z" },
+ { url =
"https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl",
hash =
"sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size
= 14460260, upload-time = "2025-12-18T19:29:27.808Z" },
+ { url =
"https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl",
hash =
"sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size
= 14229978, upload-time = "2025-12-18T19:29:11.32Z" },
+ { url =
"https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl",
hash =
"sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size
= 14338036, upload-time = "2025-12-18T19:29:09.184Z" },
+ { url =
"https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl",
hash =
"sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size
= 13264051, upload-time = "2025-12-18T19:29:13.431Z" },
+ { url =
"https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl",
hash =
"sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size
= 13283998, upload-time = "2025-12-18T19:29:06.994Z" },
+ { url =
"https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl",
hash =
"sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size
= 13601891, upload-time = "2025-12-18T19:28:55.811Z" },
+ { url =
"https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl",
hash =
"sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size
= 14336660, upload-time = "2025-12-18T19:29:16.531Z" },
+ { url =
"https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl",
hash =
"sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size
= 13196187, upload-time = "2025-12-18T19:29:19.006Z" },
+ { url =
"https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl",
hash =
"sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size
= 14661283, upload-time = "2025-12-18T19:29:30.16Z" },
+ { url =
"https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl",
hash =
"sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size
= 13729839, upload-time = "2025-12-18T19:28:48.636Z" },
+]
+
+[[package]]
+name = "semver"
+version = "3.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz",
hash =
"sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size
= 269730, upload-time = "2025-01-24T13:19:27.617Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl",
hash =
"sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size
= 17912, upload-time = "2025-01-24T13:19:24.949Z" },
]
[[package]]
@@ -1793,6 +1802,7 @@ dependencies = [
{ name = "quart-schema", extra = ["pydantic"] },
{ name = "quart-wtforms" },
{ name = "rich" },
+ { name = "semver" },
{ name = "sqlmodel" },
{ name = "standard-imghdr" },
{ name = "yyjson" },
@@ -1850,6 +1860,7 @@ requires-dist = [
{ name = "quart-schema", extras = ["pydantic"], specifier = "~=0.21" },
{ name = "quart-wtforms", specifier = "~=1.0.3" },
{ name = "rich", specifier = "~=14.0.0" },
+ { name = "semver", specifier = ">=3.0.4" },
{ name = "sqlmodel", specifier = "~=0.0.24" },
{ name = "standard-imghdr", specifier = ">=3.13.0" },
{ name = "yyjson", specifier = ">=4.0.6" },
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]