From a25c36f104e8c39c79c7c70911f3b25ddf3fea6a Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Tue, 28 Apr 2026 23:16:10 +0200 Subject: [PATCH] tools(generate-cve-json): port project-agnostic Python implementation into framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of 3 in the generate-cve-json refactor (PR 1 landed at airflow-s/airflow-s — refactored the tool to load all project-specific values from a TOML config). This commit ports the now-project-agnostic Python implementation into the apache/airflow-steward framework so the framework can ship the implementation alongside the SKILL.md description. Files added: - tools/vulnogram/generate-cve-json/pyproject.toml — Python package metadata. - tools/vulnogram/generate-cve-json/src/generate_cve_json/{cve_json,__init__,__main__}.py — the project-agnostic implementation. Loads config at startup from --config CLI flag → $CVE_JSON_CONFIG → /.apache-steward/tools/vulnogram/cve-json-config.toml. - tools/vulnogram/generate-cve-json/tests/{__init__,conftest,test_generate_cve_json}.py — full test suite (100 tests). Conftest points at a fixture config in tests/fixtures/. - tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml — TEST FIXTURE config (clearly labeled as such). Mirrors one adopter's setup so the existing tests' assertions pass without rewriting; NOT shipped as a default for adopters. - tools/vulnogram/generate-cve-json/uv.lock — uv lockfile. Files updated: - .pre-commit-config.yaml — added the four generate-cve-json hooks (ruff-check, ruff-format, mypy, pytest) restored from the airflow-s pre-commit config. - tools/vulnogram/generate-cve-json/SKILL.md — preamble note clarifying that examples use Airflow's config as illustration; the tool itself is config-driven and emits CVE records against any adopter's product taxonomy. Test plan: - All 100 tests pass against the test-fixture config. - All four pre-commit hooks pass (ruff/mypy/pytest + the standard set). Known follow-ups: - The SKILL.md still has substantial Airflow-flavoured prose in the body (provider directory examples, `apache-airflow-providers-...` package names, etc.). The preamble note flags this; tightening passes can rephrase example-by-example without changing the contract. - The test fixture config is Airflow-shaped because the tests were written against that taxonomy. A future PR could replace it with a synthetic ("Acme Project") fixture and rewrite assertions to match. PR 3 (against airflow-s) will delete the local Python implementation (it lives in the framework now via submodule) and update skill references to invoke the framework copy. Generated-by: Claude Opus 4.7 (1M context) --- .pre-commit-config.yaml | 32 + tools/vulnogram/generate-cve-json/SKILL.md | 10 + .../generate-cve-json/pyproject.toml | 94 + .../src/generate_cve_json/__init__.py | 95 + .../src/generate_cve_json/__main__.py | 24 + .../src/generate_cve_json/cve_json.py | 1757 +++++++++++++++++ .../generate-cve-json/tests/__init__.py | 0 .../generate-cve-json/tests/conftest.py | 42 + .../tests/fixtures/cve-json-config.toml | 179 ++ .../tests/test_generate_cve_json.py | 961 +++++++++ tools/vulnogram/generate-cve-json/uv.lock | 264 +++ 11 files changed, 3458 insertions(+) create mode 100644 tools/vulnogram/generate-cve-json/pyproject.toml create mode 100644 tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py create mode 100644 tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py create mode 100644 tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py create mode 100644 tools/vulnogram/generate-cve-json/tests/__init__.py create mode 100644 tools/vulnogram/generate-cve-json/tests/conftest.py create mode 100644 tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml create mode 100644 tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py create mode 100644 tools/vulnogram/generate-cve-json/uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8e063ffc..cb45c2a9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,3 +53,35 @@ repos: name: Detect if mixed line ending is used (\r vs. \r\n) - id: trailing-whitespace name: Remove trailing whitespace at end of line + # Project-local checks for the `generate-cve-json` Python project at + # `tools/vulnogram/generate-cve-json/`. Each hook sets the working + # directory via `uv run --directory` so ruff / mypy / pytest pick up + # config from the project's pyproject.toml without explicit paths. + # The `files:` pattern scopes each hook to changes inside the project + # directory. + - repo: local + hooks: + - id: generate-cve-json-ruff-check + name: ruff check (generate-cve-json) + language: system + entry: uv run --directory tools/vulnogram/generate-cve-json ruff check + files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml) + pass_filenames: false + - id: generate-cve-json-ruff-format + name: ruff format (generate-cve-json) + language: system + entry: uv run --directory tools/vulnogram/generate-cve-json ruff format --check + files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml) + pass_filenames: false + - id: generate-cve-json-mypy + name: mypy (generate-cve-json) + language: system + entry: uv run --directory tools/vulnogram/generate-cve-json mypy + files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml) + pass_filenames: false + - id: generate-cve-json-pytest + name: pytest (generate-cve-json) + language: system + entry: uv run --directory tools/vulnogram/generate-cve-json pytest + files: ^tools/vulnogram/generate-cve-json/(src|tests|pyproject\.toml) + pass_filenames: false diff --git a/tools/vulnogram/generate-cve-json/SKILL.md b/tools/vulnogram/generate-cve-json/SKILL.md index 18fb83eb..c2765dde 100644 --- a/tools/vulnogram/generate-cve-json/SKILL.md +++ b/tools/vulnogram/generate-cve-json/SKILL.md @@ -27,6 +27,16 @@ paste into the Vulnogram **"#source"** tab of the ASF CVE tool. The goal is to eliminate the manual "copy each field from the issue into the right Vulnogram form input" step when you are preparing to publish an advisory. +> **Project-agnostic by design.** All project-specific values +> (vendor, top-level product / package name, provider display map, +> CNA org id, generator tag, …) are loaded from a TOML config the +> adopting project ships at `/tools/vulnogram/cve-json-config.toml`. +> Examples in this document use Apache Airflow's configuration as a +> running illustration, since that's where the tool was originally +> developed; any adopter writes their own config per the schema in +> the package [README](README.md) and the tool emits CVE records +> against their own product taxonomy. + **Golden rule:** the script generates a *proposal* JSON document. It parses a handful of structured fields from the issue body, but it cannot read the security team member's mind. Always review the generated JSON diff --git a/tools/vulnogram/generate-cve-json/pyproject.toml b/tools/vulnogram/generate-cve-json/pyproject.toml new file mode 100644 index 00000000..7f363e74 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/pyproject.toml @@ -0,0 +1,94 @@ +# 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. +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "generate-cve-json" +version = "0.1.0" +description = "Generate a CVE 5.x JSON record from a tracking issue in the active project's security tracker (Vulnogram adapter)." +readme = "README.md" +requires-python = ">=3.11" +license = { text = "Apache-2.0" } +# Runtime deps are deliberately empty — the script is stdlib-only and shells +# out to `gh` for GitHub access. Keeping the runtime closed means `uv run` +# can resolve the environment in milliseconds. +dependencies = [] + +[project.scripts] +generate-cve-json = "generate_cve_json:main" + +[dependency-groups] +dev = [ + "mypy>=1.11", + "pytest>=8.0", + "ruff>=0.6", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/generate_cve_json"] + +[tool.ruff] +line-length = 110 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "SIM", # flake8-simplify + "C4", # flake8-comprehensions + "RUF", # ruff-specific +] +ignore = [ + "E501", # line-too-long — the 110-char limit above is already generous +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B", "SIM"] # test clarity beats these + +[tool.mypy] +python_version = "3.11" +files = ["src", "tests"] +# The script manipulates untyped JSON-dict shapes extensively; bare +# `dict` / `list` annotations are pragmatic. Keep the useful checks +# (unreachable code, implicit Optional, return types) and turn off the +# ones that would require a heavy TypedDict refactor. +warn_unused_ignores = true +warn_redundant_casts = true +warn_unreachable = true +check_untyped_defs = true +no_implicit_optional = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[[tool.mypy.overrides]] +module = "tests.*" +# Tests aren't expected to type-annotate everything and often mock freely. +disallow_untyped_defs = false +disallow_incomplete_defs = false + +[tool.pytest.ini_options] +minversion = "8.0" +addopts = "-ra -q" +testpaths = ["tests"] diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py new file mode 100644 index 00000000..f304b45b --- /dev/null +++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__init__.py @@ -0,0 +1,95 @@ +# 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. +"""Public entry points for the ``generate-cve-json`` project. + +The implementation lives in :mod:`generate_cve_json.cve_json`; this +package re-exports the names that callers (``generate-cve-json`` console +script, ``python -m generate_cve_json``, and the test suite) depend on +so they can keep using ``from generate_cve_json import X``. +""" + +from __future__ import annotations + +from generate_cve_json.cve_json import ( + COLLECTION_URL_TO_PROJECT_URL_TEMPLATE, + NEXT_VERSION_TOKEN_RE, + PROVIDER_DISPLAY_MAP, + _build_attachment_body, + _has_vendor_advisory_reference, + _is_cna_ready_for_review, + _product_for_package, + attach_to_issue, + build_affected, + build_cna_container, + build_credits, + build_descriptions, + build_metrics, + build_problem_types, + build_references, + classify_reference, + combine_remediation_developers, + compute_cna_private_state, + compute_package_url, + emit_json, + extract_field, + fetch_issue, + format_version_range, + main, + parse_affected_versions, + parse_args, + parse_credits_from_field, + parse_cve_id, + parse_cwe, + parse_url_list, + resolve_title, + wrap_cve_record, +) + +__all__ = [ + "COLLECTION_URL_TO_PROJECT_URL_TEMPLATE", + "NEXT_VERSION_TOKEN_RE", + "PROVIDER_DISPLAY_MAP", + "_build_attachment_body", + "_has_vendor_advisory_reference", + "_is_cna_ready_for_review", + "_product_for_package", + "attach_to_issue", + "build_affected", + "build_cna_container", + "build_credits", + "build_descriptions", + "build_metrics", + "build_problem_types", + "build_references", + "classify_reference", + "combine_remediation_developers", + "compute_cna_private_state", + "compute_package_url", + "emit_json", + "extract_field", + "fetch_issue", + "format_version_range", + "main", + "parse_affected_versions", + "parse_args", + "parse_credits_from_field", + "parse_cve_id", + "parse_cwe", + "parse_url_list", + "resolve_title", + "wrap_cve_record", +] diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py new file mode 100644 index 00000000..f3a1d499 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/__main__.py @@ -0,0 +1,24 @@ +# 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. +"""Entry point for ``python -m generate_cve_json``.""" + +from __future__ import annotations + +from generate_cve_json import main + +if __name__ == "__main__": + main() diff --git a/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py new file mode 100644 index 00000000..37a38f69 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/src/generate_cve_json/cve_json.py @@ -0,0 +1,1757 @@ +# 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. +"""Generate a CVE 5.x JSON record from an airflow-s/airflow-s issue. + +This script reads a tracking issue via ``gh issue view``, parses the +standard template fields in the issue body, and emits a JSON document +that can be pasted straight into the Vulnogram ``#source`` tab of the ASF +CVE tool at + + https://cveprocess.apache.org/cve5/#source + +The output shape is modelled on a real CVE record exported from +Vulnogram so that a paste-and-save round-trip does not mangle the +record. In particular, the script produces: + +* ``affected[]`` entries with ``vendor``, ``product``, ``collectionURL`` + (PyPI), ``packageName`` (``apache-airflow``) and a semver range; +* a ``descriptions[]`` entry with both a plain ``value`` and an HTML + ``supportingMedia`` alternative (Vulnogram's WYSIWYG mode expects + both); +* ``metrics[].other`` with ``type`` = *"Textual description of severity"* + and ``content.text`` = the severity word; +* ``problemTypes[].descriptions[]`` with ``cweId``, ``description`` and + ``type`` = *"CWE"*; +* ``credits[]`` entries from the *Reporter credited as* field + (``type: "finder"``) and the *Remediation developer* field + (``type: "remediation developer"``) — both newline-separated, with + the ``Full Name, Affiliation`` pattern preserved as one credit; the + ``--remediation-developer`` CLI flag still works as an additional / + override mechanism on top of the body field; +* ``references[]`` with automatic ``tags`` (``patch`` for + ``github.com/.../pull/...`` URLs, ``vendor-advisory`` for + ``lists.apache.org``/``security.apache.org``). URLs from the + tracking-issue's *Public advisory URL* body field are picked up + automatically and tagged as ``vendor-advisory``; URLs from the + *Security mailing list thread* field are **deliberately not** exported + because that field holds the internal ``security@airflow.apache.org`` + thread URL, which 404s for anyone outside the security team; +* ``providerMetadata.orgId`` and ``cveMetadata.assignerOrgId`` set to + the ASF org id; +* an ``x_generator`` annotation so the origin of the JSON is visible. + +The output is **deterministic**: the same issue body and the same CLI +flags always produce the same JSON bytes. Keys are sorted, references +are sorted alphabetically, credits preserve the order of first +occurrence, and no timestamps or machine-dependent data are included. + +Usage:: + + # Read the issue from GitHub and write the JSON to stdout: + uv run --project tools/vulnogram/generate-cve-json generate-cve-json 232 + + # Write to a file and show the Vulnogram paste URL: + uv run --project tools/vulnogram/generate-cve-json generate-cve-json 232 \\ + --output /tmp/CVE-2026-40913.json + + # Attach an explicit "remediation developer" credit: + uv run --project tools/vulnogram/generate-cve-json generate-cve-json 232 \\ + --remediation-developer "Kevin Yang" + + # Test mode: read a prepared issue body from stdin so you can + # iterate on the parser without touching the network: + cat /tmp/issue232-body.md | \\ + uv run --project tools/vulnogram/generate-cve-json generate-cve-json \\ + --stdin --cve-id CVE-2026-40913 \\ + --title "Apache Airflow: ..." + +The runtime uses only the Python standard library; the ``pyproject.toml`` +declares no runtime dependencies, so ``uv run`` resolves the environment +in milliseconds. Dev tooling (pytest, ruff, mypy) lives in the ``dev`` +dependency group. +""" + +from __future__ import annotations + +import argparse +import contextlib +import html +import json +import os +import re +import subprocess +import sys +import tempfile + +# ----------------------------------------------------------------------------- +# Configuration loading. +# ----------------------------------------------------------------------------- +# +# All project-specific values (vendor, top-level product/package, provider +# display map, package-name regex, CNA org id, generator tag, …) are read +# at module load time from a TOML config file the adopting project ships +# in its tracker repo at: +# +# /tools/vulnogram/cve-json-config.toml +# +# (where `` is the adopting project's `.apache-steward/` +# directory, per the apache/airflow-steward placeholder convention). +# +# Resolution order for the config path: +# 1. The `--config ` CLI flag (or the `config_path=` argument to +# `_load_config()`). +# 2. The `CVE_JSON_CONFIG` environment variable. +# 3. `/.apache-steward/tools/vulnogram/cve-json-config.toml`. +# +# Schema: see the README in this package. +import tomllib +from pathlib import Path + +_DEFAULT_CONFIG_RELPATH = ".apache-steward/tools/vulnogram/cve-json-config.toml" +_CONFIG_PATH_ENV = "CVE_JSON_CONFIG" + +# Cached, lazily-loaded config. `_populate_constants()` reads this and +# fills the module globals below; tests that want to use a different +# config can call `_set_config_path(path)` before calling any +# generator function. +_CONFIG: dict | None = None + + +def _resolve_config_path(config_path: Path | str | None = None) -> Path: + """Resolve the config file path following the documented order.""" + if config_path is not None: + return Path(config_path) + env = os.environ.get(_CONFIG_PATH_ENV) + if env: + return Path(env) + return Path.cwd() / _DEFAULT_CONFIG_RELPATH + + +def _load_config(config_path: Path | str | None = None) -> dict: + """Load TOML config, raising ``FileNotFoundError`` with usage hints + if it cannot be located. + """ + path = _resolve_config_path(config_path) + if not path.exists(): + raise FileNotFoundError( + f"generate-cve-json: config file not found at {path}.\n" + f" Pass --config , set ${_CONFIG_PATH_ENV}, or place a config\n" + f" at {_DEFAULT_CONFIG_RELPATH} relative to the cwd.\n" + f" Schema: see the generate-cve-json package README." + ) + return tomllib.loads(path.read_text()) + + +def _set_config_path(config_path: Path | str) -> None: + """Set / override the config path (used by `--config` CLI and tests).""" + global _CONFIG + _CONFIG = _load_config(config_path) + _populate_constants() + + +def _populate_constants() -> None: + """(Re)populate module globals from the loaded config.""" + global _CONFIG + if _CONFIG is None: + _CONFIG = _load_config() + cfg = _CONFIG + + global DEFAULT_REPO, DEFAULT_VENDOR, DEFAULT_PRODUCT + global DEFAULT_PACKAGE_NAME, DEFAULT_COLLECTION_URL + global DEFAULT_ASF_ORG_ID, GENERATOR_TAG, SKILL_SOURCE_URL + global PROVIDER_DISPLAY_MAP, PACKAGE_RE + global TOP_LEVEL_NAME, TOP_LEVEL_PRODUCT, PROVIDER_PRODUCT_TEMPLATE + global PROVIDER_PREFIX + + DEFAULT_REPO = cfg["meta"]["tracker_repo"] + DEFAULT_VENDOR = cfg["product"]["vendor"] + DEFAULT_PRODUCT = cfg["product"]["default_product"] + DEFAULT_PACKAGE_NAME = cfg["product"]["default_package_name"] + DEFAULT_COLLECTION_URL = cfg["product"]["default_collection_url"] + DEFAULT_ASF_ORG_ID = cfg["cna"]["org_id"] + GENERATOR_TAG = cfg["meta"]["generator_tag"] + SKILL_SOURCE_URL = cfg["meta"].get( + "skill_source_url", f"https://github.com/{cfg['meta']['tracker_repo']}" + ) + PROVIDER_DISPLAY_MAP = dict(cfg["packages"]["provider_display_map"]) + PACKAGE_RE = re.compile(cfg["packages"]["package_pattern"]) + TOP_LEVEL_NAME = cfg["packages"]["top_level_name"] + TOP_LEVEL_PRODUCT = cfg["packages"]["top_level_product"] + PROVIDER_PRODUCT_TEMPLATE = cfg["packages"]["provider_product_template"] + # Convenient derived constant: the prefix used to detect + # "provider" subpackages (e.g. ``apache-airflow-providers-``). + PROVIDER_PREFIX = f"{TOP_LEVEL_NAME}-providers-" + + +# Module-level constants — populated by `_populate_constants()`. The +# initial values are placeholders; the call below populates them at +# import time. Tests can override by calling `_set_config_path()` +# before importing any generator function. +DEFAULT_REPO: str = "" +DEFAULT_VENDOR: str = "" +DEFAULT_PRODUCT: str = "" +DEFAULT_PACKAGE_NAME: str = "" +DEFAULT_COLLECTION_URL: str = "" +DEFAULT_ASF_ORG_ID: str = "" +GENERATOR_TAG: str = "" +SKILL_SOURCE_URL: str = "" +PROVIDER_DISPLAY_MAP: dict[str, str] = {} +PACKAGE_RE: re.Pattern[str] = re.compile("") +TOP_LEVEL_NAME: str = "" +TOP_LEVEL_PRODUCT: str = "" +PROVIDER_PRODUCT_TEMPLATE: str = "" +PROVIDER_PREFIX: str = "" + +# CVE 5.x convention values that are not project-specific. +DEFAULT_CREDIT_TYPE = "finder" +DEFAULT_LANG = "en" +DEFAULT_DISCOVERY = "UNKNOWN" + +# Populate at import time. If the config is not present, defer the +# error to first actual use so test harnesses can call +# `_set_config_path()` before exercising the module. +with contextlib.suppress(FileNotFoundError): + _populate_constants() + +NO_RESPONSE = "_No response_" + +# Sentinel token for an as-yet-unreleased upper bound in +# ``Affected versions`` entries. Used predominantly for providers +# trackers where the wave milestone (date-based, e.g. ``Providers +# 2026-04-21``) does not reveal which exact provider package version +# will ship the fix. The token is stripped before parsing and the +# resulting CVE 5.x ``versions[]`` entry omits ``lessThan`` — +# downstream consumers (Vulnogram, cve.org) read that as +# *"affected from onwards, no fix released yet"*. Once the +# release ships and the version is known the sync skill replaces the +# token with the real upper bound (``< X.Y.Z``). +NEXT_VERSION_TOKEN_RE = re.compile(r"<\s*NEXT\s+VERSION\b", re.IGNORECASE) + +# Map a CVE ``affected[].collectionURL`` to the canonical project-page +# URL template used to render a clickable per-package link in the +# attachment table. The PyPI ``collectionURL`` is the legacy +# ``https://pypi.python.org`` host, but the canonical project page lives +# on ``pypi.org/project//``; the table renders that. +# Only collection URLs Airflow actually emits today need entries here — +# unknown URLs fall through to ``None`` and the attachment table +# renders ``—`` for them. +COLLECTION_URL_TO_PROJECT_URL_TEMPLATE: dict[str, str] = { + "https://pypi.python.org": "https://pypi.org/project/{package}/", + "https://pypi.org": "https://pypi.org/project/{package}/", +} + +# HTML-comment marker prefix used to identify the single "CVE JSON +# attachment" comment on a tracking issue, so ``--attach`` can update +# the existing comment in place instead of posting a duplicate on every +# re-run. The full marker includes the CVE id and a version tag. +ATTACHMENT_MARKER_BEGIN_PREFIX = "" + ) + + +def _attachment_marker_end(cve_id: str) -> str: + return f"{ATTACHMENT_MARKER_END_PREFIX} cve={cve_id or 'UNKNOWN'} version={ATTACHMENT_MARKER_VERSION} -->" + + +def _escape_table_cell(value: str) -> str: + """Escape characters that would break a markdown table cell.""" + if not value: + return "" + return value.replace("|", "\\|").replace("\n", " ").strip() + + +def _build_affected_table(affected: list[dict]) -> str: + """Return the markdown table summarising ``affected[]`` for the + attachment block. + + One row per package (so multi-product CVEs surface every entry the + JSON will land in Vulnogram). Columns: package name, product, + versions (rendered back to ``>= X, < Y`` shape via + :func:`format_version_range`), and a clickable PyPI project-page + URL derived from ``collectionURL`` + ``packageName`` so a reader + can jump straight to the package without reconstructing the URL. + """ + if not affected: + return "" + header = "| # | Package | Product | Versions | PyPI URL |\n| :-- | :-- | :-- | :-- | :-- |\n" + rows: list[str] = [] + for index, entry in enumerate(affected, start=1): + package = (entry.get("packageName") or "").strip() + product = (entry.get("product") or "").strip() + versions = format_version_range(entry.get("versions") or []) + package_url = compute_package_url(entry.get("collectionURL") or "", package) + package_cell = f"`{_escape_table_cell(package)}`" if package else "—" + product_cell = _escape_table_cell(product) or "—" + versions_cell = f"`{_escape_table_cell(versions)}`" if versions else "—" + url_cell = f"<{package_url}>" if package_url else "—" + rows.append(f"| {index} | {package_cell} | {product_cell} | {versions_cell} | {url_cell} |") + return header + "\n".join(rows) + "\n" + + +def _build_attachment_body( + *, + cve_id: str, + json_text: str, + cna: dict, + cna_private_state: str | None, +) -> str: + """Return the markdown fragment embedded into the issue body. + + The fragment starts with a ```` + begin marker and ends with a matching ``:end …`` marker, so + subsequent re-runs can locate and replace this exact block without + guessing at boundaries. The JSON itself is wrapped in a + ``
`` disclosure so a long payload does not bloat the + issue view, and placed inside a four-backtick fenced block so any + three-backtick tokens inside the JSON (unlikely, but defensive) + don't break the markdown parse. + + The summary table above the JSON surfaces the CVE id, title, the + Vulnogram workflow state (``cna_private_state``), credit / + reference counts, and a per-package sub-table so a reader can see + *what* the JSON will land in Vulnogram without expanding the + payload. + """ + begin_marker = _attachment_marker_begin(cve_id) + end_marker = _attachment_marker_end(cve_id) + byte_count = len(json_text.encode("utf-8")) + cve_heading = "CVE JSON — paste-ready" + if cve_id: + cve_link = f"[`{cve_id}`](https://cveprocess.apache.org/cve5/{cve_id}#source)" + cve_heading = f"CVE JSON — paste-ready for {cve_link}" + script_link = f"[`generate_cve_json.py`]({SKILL_SOURCE_URL})" + title = (cna.get("title") or "").strip() + affected = cna.get("affected") or [] + credit_count = len(cna.get("credits") or []) + reference_count = len(cna.get("references") or []) + title_cell = _escape_table_cell(title) or "—" + state_cell = f"`{cna_private_state}`" if cna_private_state else "— (`--no-envelope`)" + package_names = [_escape_table_cell((entry.get("packageName") or "").strip()) for entry in affected] + package_names = [name for name in package_names if name] + packages_cell = ", ".join(f"`{name}`" for name in package_names) if package_names else "—" + metric_table = ( + f"| Metric | Value |\n" + f"| :-- | :-- |\n" + f"| CVE ID | `{cve_id or 'UNKNOWN'}` |\n" + f"| Title | {title_cell} |\n" + f"| Vulnogram state | {state_cell} |\n" + f"| Affected packages | {packages_cell} |\n" + f"| Credits | {credit_count} |\n" + f"| References | {reference_count} |\n" + f"| Size | {byte_count} bytes |\n" + ) + package_block = "" + affected_table = _build_affected_table(affected) + if affected_table: + package_block = f"\n**Packages this JSON covers:**\n\n{affected_table}" + return ( + f"{begin_marker}\n\n" + f"## {cve_heading}\n\n" + f"Generated by {script_link} from the current state of this " + f"issue body. The JSON is **deterministic**: re-running the " + f"skill after updating the body (for example once the reporter " + f"confirms a credit form, or the affected version range is " + f"narrowed) produces a byte-for-byte identical payload.\n\n" + f"**Paste into Vulnogram** via the `#source` tab at the CVE tool " + f"URL above and click save; the form view reflects the new " + f"values.\n\n" + f"{metric_table}" + f"{package_block}\n" + f"
\n" + f"CVE 5.x JSON ({byte_count} bytes) — click to " + f"expand\n\n" + f"````json\n" + f"{json_text}\n" + f"````\n\n" + f"
\n\n" + f"{end_marker}\n" + ) + + +def _gh_api_json( + args: list[str], + *, + body_payload: dict | None = None, +) -> dict | list: + """Run ``gh api`` and return the parsed JSON stdout. + + When ``body_payload`` is supplied, it is serialised to a temp file + and passed via ``--input`` so ``gh api`` uses it as the request + body (this is how ``gh api`` wants long JSON bodies for ``POST`` / + ``PATCH`` calls -- ``-f`` URL-encodes and mangles newlines). + """ + tmp_path: str | None = None + try: + if body_payload is not None: + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle: + json.dump(body_payload, handle) + tmp_path = handle.name + args = [*args, "--input", tmp_path] + result = subprocess.run( + ["gh", "api", *args], + check=True, + capture_output=True, + text=True, + ) + except FileNotFoundError as exc: + raise RuntimeError("`gh` CLI not found on PATH. Install it from https://cli.github.com/.") from exc + except subprocess.CalledProcessError as exc: + raise RuntimeError(f"`gh api {' '.join(args)}` failed:\n{exc.stderr.strip()}") from exc + finally: + if tmp_path is not None: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + + if not result.stdout.strip(): + return {} + return json.loads(result.stdout) + + +def _fetch_issue(repo: str, issue_number: str) -> dict: + """Return the full issue dict from the REST API, including ``body`` and ``html_url``.""" + response = _gh_api_json([f"repos/{repo}/issues/{issue_number}"]) + if not isinstance(response, dict): + raise RuntimeError(f"Unexpected response shape for `gh api repos/{repo}/issues/{issue_number}`") + return response + + +def _splice_attachment_into_body(issue_body: str, attachment: str, cve_id: str) -> str: + """Return ``issue_body`` with the CVE-JSON attachment spliced in place. + + Behaviour, in order of preference: + + - If the body already contains a ``generate-cve-json`` begin/end marker + pair for this CVE, replace the block between them with the new + attachment. + - Otherwise, if the body contains a ``### CVE tool link`` field, + append the attachment *after* that field's value, i.e. right at + the end of the template. + - Otherwise, append the attachment at the very end of the body. + + The body is returned with a trailing newline. + """ + begin_marker = _attachment_marker_begin(cve_id) + end_marker = _attachment_marker_end(cve_id) + + # Regex that matches begin marker ... end marker, non-greedy, across lines. + # The marker text itself contains characters that need escaping. + begin_escaped = re.escape(begin_marker) + end_escaped = re.escape(end_marker) + pattern = rf"{begin_escaped}.*?{end_escaped}\n?" + if re.search(pattern, issue_body, re.DOTALL): + new_body = re.sub(pattern, attachment.rstrip("\n") + "\n", issue_body, count=1, flags=re.DOTALL) + return new_body.rstrip() + "\n" + + # Also tolerate a legacy single-marker attachment that was embedded + # without the matching end marker (shouldn't happen for our content, + # but defensive): drop from the begin marker to end-of-body and + # re-append. + legacy_pattern = rf"{begin_escaped}.*\Z" + if re.search(legacy_pattern, issue_body, re.DOTALL): + stripped = re.sub(legacy_pattern, "", issue_body, count=1, flags=re.DOTALL).rstrip() + return f"{stripped}\n\n{attachment.rstrip()}\n" + + # No existing block. Try to locate the CVE-tool-link field so we can + # append after the whole template rather than breaking it in half. + # The CVE-tool-link field is the last one in the standard template, + # so appending after it naturally puts the attachment at the end. + cve_tool_link_pattern = r"^###\s+CVE tool link\s*\n+.*?(?=\n###\s|\Z)" + match = re.search(cve_tool_link_pattern, issue_body, re.MULTILINE | re.DOTALL) + if match is not None: + insertion_point = match.end() + prefix = issue_body[:insertion_point].rstrip() + suffix = issue_body[insertion_point:].rstrip() + middle = f"\n\n{attachment.rstrip()}\n" + if suffix: + return f"{prefix}{middle}\n\n{suffix}\n" + return f"{prefix}{middle}" + + return issue_body.rstrip() + f"\n\n{attachment.rstrip()}\n" + + +def attach_to_issue( + *, + issue_number: str, + repo: str, + cve_id: str, + json_text: str, + cna: dict, + cna_private_state: str | None, +) -> tuple[str, bool]: + """Embed the CVE-JSON attachment in the issue body; update idempotently. + + The attachment used to be posted as a separate issue comment, but + GitHub has no pin-comment API, so the comment ended up buried below + every status-change comment and newcomers had no idea it existed. + Embedding in the issue body puts it above the entire comment + timeline permanently without needing any pin mechanism. + + Returns ``(html_url, was_update)`` where ``html_url`` is a stable + anchor into the issue body at the CVE-JSON section heading (GitHub + auto-generates heading anchors from the ``## CVE JSON — paste-ready + …`` text), and ``was_update`` is ``True`` when an existing + attachment was replaced in place, ``False`` when the attachment was + appended to the body for the first time. + """ + attachment = _build_attachment_body( + cve_id=cve_id, + json_text=json_text, + cna=cna, + cna_private_state=cna_private_state, + ) + issue = _fetch_issue(repo, issue_number) + current_body = issue.get("body") or "" + had_existing = _attachment_marker_begin(cve_id) in current_body + new_body = _splice_attachment_into_body(current_body, attachment, cve_id) + + if new_body.rstrip() == current_body.rstrip(): + # Byte-for-byte identical — skip the PATCH to avoid a no-op update + # timestamp on the issue. + html_url = issue.get("html_url", "") + return (html_url, had_existing) + + _gh_api_json( + [ + "-X", + "PATCH", + f"repos/{repo}/issues/{issue_number}", + ], + body_payload={"body": new_body}, + ) + html_url = issue.get("html_url", "") + # Point the returned URL at the CVE-JSON heading anchor inside the + # body. GitHub renders the H2 as `#cve-json--paste-ready-for-cve-...`. + if cve_id: + anchor_slug = f"cve-json--paste-ready-for-{cve_id.lower()}" + html_url = f"{html_url}#{anchor_slug}" if html_url else "" + return (html_url, had_existing) + + +# ----------------------------------------------------------------------------- +# Entry point. +# ----------------------------------------------------------------------------- + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Generate a CVE 5.x JSON record from a tracker issue, ready " + "to paste into the Vulnogram #source tab of the ASF CVE tool. " + "Project-specific defaults (vendor, product, package map, " + "CNA org id, …) come from a TOML config file the adopting " + "project ships at `/tools/vulnogram/" + "cve-json-config.toml`." + ), + ) + parser.add_argument( + "--config", + type=Path, + default=None, + help=( + f"Path to the TOML config file. Defaults to " + f"$CVE_JSON_CONFIG or {_DEFAULT_CONFIG_RELPATH} relative to cwd." + ), + ) + parser.add_argument( + "issue", + nargs="?", + help="Issue number in the tracker repo (omit when --stdin is used).", + ) + parser.add_argument( + "--repo", + default=None, + help="GitHub repo holding the tracking issue (default from config: tracker_repo).", + ) + parser.add_argument( + "--output", + type=Path, + help=("Write the resulting JSON to this path. When omitted the JSON is printed to stdout."), + ) + parser.add_argument( + "--stdin", + action="store_true", + help=( + "Read the issue body from stdin instead of calling `gh`. Useful " + "for offline iteration and for the skill's test mode." + ), + ) + parser.add_argument( + "--cve-id", + help=("Override the CVE ID. Defaults to the id parsed from the issue's `CVE tool link` field."), + ) + parser.add_argument( + "--title", + help=( + "Override the CVE title. Defaults to the GitHub issue title " + "with an `Apache Airflow: ` prefix when not already present." + ), + ) + parser.add_argument( + "--vendor", + default=None, + help="CVE vendor name (default from config: product.vendor).", + ) + parser.add_argument( + "--product", + default=None, + help="CVE product name (default from config: product.default_product).", + ) + parser.add_argument( + "--package-name", + default=None, + help="Package name (default from config: product.default_package_name).", + ) + parser.add_argument( + "--collection-url", + default=None, + help="Package collection URL (default from config: product.default_collection_url).", + ) + parser.add_argument( + "--product-for", + action="append", + default=[], + metavar="PACKAGE=PRODUCT", + help=( + "Override the CVE product name for a specific packageName, " + "e.g. `--product-for apache-airflow-providers-foo='Apache " + "Airflow Foo Provider'`. Useful when a provider is not yet " + "in the built-in display-name map, or when the default " + "title-cased fallback needs a different casing. Repeat the " + "flag to override multiple packages." + ), + ) + parser.add_argument( + "--org-id", + default=None, + help="CNA assigner org id (default from config: cna.org_id).", + ) + parser.add_argument( + "--version-start", + help=( + "Override the version range start (the value put in " + "`affected[].versions[].version`). Defaults to the lower bound " + "parsed from the Affected versions field when it uses " + "`>= X, < Y` syntax, otherwise `0`." + ), + ) + parser.add_argument( + "--discovery", + default=DEFAULT_DISCOVERY, + help=( + f"Value for `source.discovery` (default: {DEFAULT_DISCOVERY!r}). " + "Valid CVE 5.x values include UNKNOWN, INTERNAL, EXTERNAL, USER." + ), + ) + parser.add_argument( + "--remediation-developer", + action="append", + default=[], + metavar="NAME", + help=( + "Name to add to `credits[]` with type 'remediation developer'. " + "In normal use, the developer name lives in the issue body's " + "*Remediation developer* field (auto-populated by the " + "`sync-security-issue` skill from the linked PR's author). " + "This flag is an additional / override mechanism: names " + "passed here are appended to whatever the body already " + "specifies, with duplicates silently dropped. Repeat the " + "flag to credit multiple people." + ), + ) + parser.add_argument( + "--advisory-url", + action="append", + default=[], + metavar="URL", + help=( + "Public advisory URL to add to `references[]` as a " + "`vendor-advisory` reference. Repeat the flag to add multiple " + "URLs. Any URL already present in the tracking-issue's " + "'Public advisory URL' body field is picked up automatically, " + "so in the normal flow the release manager populates that " + "field once the advisory is archived on " + "`users@airflow.apache.org` / `announce@apache.org` and this " + "flag is only needed for ad-hoc overrides. The script never " + "pulls URLs from the 'Security mailing list thread' field — " + "that field is the internal `security@airflow.apache.org` " + "thread, which 404s for anyone outside the security team. See " + "the 'CVE references must never point at non-public mailing-" + "list threads' section of `AGENTS.md` for the full rationale." + ), + ) + parser.add_argument( + "--no-envelope", + action="store_true", + help=( + "Emit only the `cna` container instead of the full CVE 5.x " + "record. Use this if Vulnogram's #source tab is in 'inner block " + "only' mode." + ), + ) + parser.add_argument( + "--attach", + action="store_true", + help=( + "After generating the JSON, embed it at the end of the " + "tracking issue's **body** (wrapped in a collapsible " + "`
` block). The attachment lives *in the body*, " + "not as a separate comment, so it stays above every " + "status-change comment in the timeline — effectively " + "pinned without needing any pin mechanism. If a previous " + "attachment from this script already exists in the body " + "(matched by a `` begin " + "marker), the block between the begin/end markers is " + "replaced in place — no duplicates. Requires the positional " + "issue argument (cannot be combined with --stdin)." + ), + ) + return parser.parse_args(argv) + + +def resolve_title( + issue_title: str, + summary: str, + override: str | None, +) -> str: + if override: + title = override.strip() + else: + title = issue_title.strip() + if not title: + title = summary.split(".", 1)[0].strip() if summary else "" + # The CNA container is already scoped to Apache/Airflow, so the + # repeated "Apache Airflow:" prefix on the title is noise. Strip it + # (along with any trailing punctuation/whitespace) if present. + title = re.sub( + r"^\s*apache\s+airflow\s*[:\-–—]?\s*", # noqa: RUF001 — en-dash/em-dash are deliberate regex matches + "", + title, + flags=re.IGNORECASE, + ) + return title + + +def emit_json(obj: dict, output: Path | None) -> str: + text = json.dumps(obj, indent=4, sort_keys=True, ensure_ascii=False) + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(text + "\n", encoding="utf-8") + return text + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + # Load config (CLI flag overrides env var overrides default path). + try: + if args.config is not None: + _set_config_path(args.config) + else: + _populate_constants() + except FileNotFoundError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + # Apply config-derived defaults to args that defaulted to None. + if args.repo is None: + args.repo = DEFAULT_REPO + if args.vendor is None: + args.vendor = DEFAULT_VENDOR + if args.product is None: + args.product = DEFAULT_PRODUCT + if args.package_name is None: + args.package_name = DEFAULT_PACKAGE_NAME + if args.collection_url is None: + args.collection_url = DEFAULT_COLLECTION_URL + if args.org_id is None: + args.org_id = DEFAULT_ASF_ORG_ID + + if args.attach and args.stdin: + print( + "error: --attach cannot be combined with --stdin (there is no real issue to attach to)", + file=sys.stderr, + ) + return 2 + if args.attach and not args.issue: + print( + "error: --attach requires the positional issue argument", + file=sys.stderr, + ) + return 2 + + if args.stdin: + body = sys.stdin.read() + issue_title = args.title or "" + else: + if not args.issue: + print( + "error: issue number is required unless --stdin is used", + file=sys.stderr, + ) + return 2 + try: + issue_title, body = fetch_issue(args.issue, args.repo) + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + summary = extract_field(body, "Short public summary for publish") + affected_field = extract_field(body, "Affected versions") + mailing_list = extract_field(body, "Security mailing list thread") + public_advisory_field = extract_field(body, "Public advisory URL") + credited_as = extract_field(body, "Reporter credited as") + pr_field = extract_field(body, "PR with the fix") + remediation_developer_field = extract_field(body, "Remediation developer") + cwe = extract_field(body, "CWE") + severity = extract_field(body, "Severity") + cve_tool = extract_field(body, "CVE tool link") + + cve_id = args.cve_id or parse_cve_id(cve_tool) + title = resolve_title(issue_title, summary, args.title) + description = summary or title + + # Advisory URLs come from two sources: the body's "Public advisory URL" + # field (populated by the release manager / sync-security-issue skill + # once the advisory is archived on users@airflow.apache.org) and any + # --advisory-url CLI overrides. Both are forwarded to the references + # builder; de-duplication happens there. + body_advisory_urls = parse_url_list(public_advisory_field) + combined_advisory_urls = [*body_advisory_urls, *args.advisory_url] + + combined_remediation_developers = combine_remediation_developers( + remediation_developer_field, + args.remediation_developer, + ) + + product_overrides: dict[str, str] = {} + for item in args.product_for: + if "=" not in item: + print( + f"error: --product-for expects PACKAGE=PRODUCT, got {item!r}", + file=sys.stderr, + ) + return 2 + pkg, _, display = item.partition("=") + pkg = pkg.strip() + display = display.strip() + if not pkg or not display: + print( + f"error: --product-for expects non-empty PACKAGE and PRODUCT, got {item!r}", + file=sys.stderr, + ) + return 2 + product_overrides[pkg] = display + + cna = build_cna_container( + title=title, + description=description, + affected_versions_value=affected_field, + cwe_value=cwe, + severity_value=severity, + credits_value=credited_as, + mailing_list_value=mailing_list, + pr_value=pr_field, + vendor=args.vendor, + product=args.product, + package_name=args.package_name, + collection_url=args.collection_url, + org_id=args.org_id, + version_start=args.version_start, + discovery=args.discovery, + remediation_developers=combined_remediation_developers, + advisory_urls=combined_advisory_urls, + product_overrides=product_overrides, + ) + + if args.no_envelope: + payload: dict = cna + else: + payload = wrap_cve_record(cna, cve_id=cve_id, org_id=args.org_id) + + text = emit_json(payload, args.output) + if args.output is None: + sys.stdout.write(text + "\n") + else: + print(f"Wrote {len(text.encode('utf-8'))} bytes to {args.output}") + if cve_id: + print( + "Open the Vulnogram #source tab and paste the file:\n" + f" https://cveprocess.apache.org/cve5/{cve_id}#source" + ) + + if args.attach: + try: + attachment_url, was_update = attach_to_issue( + issue_number=args.issue, + repo=args.repo, + cve_id=cve_id, + json_text=text, + cna=cna, + cna_private_state=None if args.no_envelope else compute_cna_private_state(cna, cve_id), + ) + except RuntimeError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + action = "Replaced" if was_update else "Embedded" + print(f"{action} CVE JSON in issue body on {args.repo}#{args.issue}") + if attachment_url: + print(f" {attachment_url}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vulnogram/generate-cve-json/tests/__init__.py b/tools/vulnogram/generate-cve-json/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/vulnogram/generate-cve-json/tests/conftest.py b/tools/vulnogram/generate-cve-json/tests/conftest.py new file mode 100644 index 00000000..e40c8d78 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/tests/conftest.py @@ -0,0 +1,42 @@ +# 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. +"""Pytest conftest for generate-cve-json. + +The Python tool loads all project-specific values from a TOML config +the adopting project ships at: + + /.apache-steward/tools/vulnogram/cve-json-config.toml + +The tool reads that config relative to ``cwd`` (or via the +``CVE_JSON_CONFIG`` environment variable / the ``--config`` CLI flag). + +For the framework's own pytest suite (run from this repository, no +adopter present), we point ``CVE_JSON_CONFIG`` at the test fixture at +``tests/fixtures/cve-json-config.toml``. The fixture mirrors one +adopter's configuration so the existing tests keep their assertions; +**it is not the framework's default configuration** — adopters write +their own per the schema in the package README. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +_FIXTURE_CONFIG = Path(__file__).resolve().parent / "fixtures" / "cve-json-config.toml" +assert _FIXTURE_CONFIG.exists(), f"missing test fixture: {_FIXTURE_CONFIG}" +os.environ.setdefault("CVE_JSON_CONFIG", str(_FIXTURE_CONFIG)) diff --git a/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml new file mode 100644 index 00000000..b804b945 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/tests/fixtures/cve-json-config.toml @@ -0,0 +1,179 @@ +# 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. + +# generate-cve-json — TEST FIXTURE config. +# +# This file is a fixture for the tool's own pytest suite, NOT the +# framework's default configuration. It carries values that match the +# Apache Airflow security team's adopter-side config because the +# tests in `test_generate_cve_json.py` were written against that +# specific configuration shape (Airflow-specific package names, +# provider display map, etc.). +# +# Adopting projects MUST NOT copy this file. Write your own +# `cve-json-config.toml` from scratch using the schema documented in +# the package README. The values here are illustrative of one +# specific adopter's configuration; nothing in the framework treats +# them as defaults. + +[product] +# CVE 5.x `affected[].vendor` — the CNA vendor name. +vendor = "Apache Software Foundation" + +# Top-level product display name and package name. Used as the default +# `product` / `packageName` for *Affected versions* lines that don't +# match a more specific entry in `[packages]` below. +default_product = "Apache Airflow" +default_package_name = "apache-airflow" + +# Where the package is distributed (`affected[].collectionURL`). +default_collection_url = "https://pypi.python.org" + +[cna] +# CNA assigner UUID (CVE 5.x `cveMetadata.assignerOrgId` / +# `providerMetadata.orgId`). This is the ASF org id. +org_id = "f0158376-9dc2-43b6-827c-5f631a4d8d09" + +[meta] +# Tracker repo slug (org/name). Used for the `x_generator.engine` +# tag in the CVE record and for the self-source-link below. +tracker_repo = "airflow-s/airflow-s" + +# Stable identifier embedded in the CVE record's `x_generator.engine` +# field so a reader can identify which tool produced the JSON. +generator_tag = "airflow-s/generate_cve_json.py" + +# Self-source URL the script can self-document with (e.g. in error +# messages, on the `_print_skill_link` line of the generated comment). +# Optional: defaults to f"https://github.com/{tracker_repo}". +skill_source_url = "https://github.com/airflow-s/airflow-s/tree/airflow-s/tools/vulnogram/generate-cve-json" + +[packages] +# Regex matching this project's package names. Required named groups: +# `package` — the full package name as shipped on PyPI (whatever +# collection_url declares). +# `provider` — optional; the subpackage / provider directory name +# used to look up a display name in +# `provider_display_map` below. May be empty when the +# line names the top-level package only. +# `rest` — optional; everything after the package name, used +# by the script to consume the trailing version-range +# expression. +package_pattern = '^(?Papache-airflow(?:-providers-(?P[a-z0-9][a-z0-9_-]*))?)(?:\s+(?P.*))?$' + +# Top-level package name + product. When the matched `package_pattern` +# yields just the top-level name (no `provider` group), the product is +# `top_level_product`. When `provider` is present, the product is +# `provider_product_template` with `{display}` substituted from +# `provider_display_map` (or a title-cased fallback). +top_level_name = "apache-airflow" +top_level_product = "Apache Airflow" +provider_product_template = "Apache Airflow Providers {display}" + +# Provider directory-name → vendor-preferred display casing for the +# CVE `product` field. Provider directory names are lowercase +# (`elasticsearch`, `cncf-kubernetes`); CVE product names follow +# vendor-preferred casing (`Elasticsearch`, `CNCF Kubernetes`). +# Extend this table when a new provider appears in a CVE; unknown +# providers fall back to a title-cased dash-split of the directory +# name, which is correct for most single-word providers but may need +# an entry here for acronyms. +[packages.provider_display_map] +"cncf-kubernetes" = "CNCF Kubernetes" +"elasticsearch" = "Elasticsearch" +"opensearch" = "OpenSearch" +"smtp" = "SMTP" +"ssh" = "SSH" +"ftp" = "FTP" +"http" = "HTTP" +"imap" = "IMAP" +"openfaas" = "OpenFaaS" +"openlineage" = "OpenLineage" +"mysql" = "MySQL" +"postgres" = "PostgreSQL" +"sqlite" = "SQLite" +"odbc" = "ODBC" +"jdbc" = "JDBC" +"sftp" = "SFTP" +"amazon" = "Amazon" +"google" = "Google" +"microsoft-azure" = "Microsoft Azure" +"microsoft-mssql" = "Microsoft SQL Server" +"microsoft-winrm" = "Microsoft WinRM" +"apache-beam" = "Apache Beam" +"apache-cassandra" = "Apache Cassandra" +"apache-drill" = "Apache Drill" +"apache-druid" = "Apache Druid" +"apache-flink" = "Apache Flink" +"apache-hdfs" = "Apache HDFS" +"apache-hive" = "Apache Hive" +"apache-impala" = "Apache Impala" +"apache-kafka" = "Apache Kafka" +"apache-kylin" = "Apache Kylin" +"apache-livy" = "Apache Livy" +"apache-pig" = "Apache Pig" +"apache-pinot" = "Apache Pinot" +"apache-spark" = "Apache Spark" +"apache-tinkerpop" = "Apache TinkerPop" +"celery" = "Celery" +"cohere" = "Cohere" +"common-compat" = "Common Compat" +"common-io" = "Common IO" +"common-messaging" = "Common Messaging" +"common-sql" = "Common SQL" +"databricks" = "Databricks" +"datadog" = "Datadog" +"dbt-cloud" = "dbt Cloud" +"dingding" = "DingTalk" +"discord" = "Discord" +"docker" = "Docker" +"edge3" = "Edge3" +"exasol" = "Exasol" +"fab" = "FAB" +"facebook" = "Facebook" +"git" = "Git" +"github" = "GitHub" +"grpc" = "gRPC" +"hashicorp" = "HashiCorp" +"jenkins" = "Jenkins" +"mongo" = "MongoDB" +"neo4j" = "Neo4j" +"openai" = "OpenAI" +"oracle" = "Oracle" +"pagerduty" = "PagerDuty" +"papermill" = "Papermill" +"pgvector" = "pgvector" +"pinecone" = "Pinecone" +"qdrant" = "Qdrant" +"redis" = "Redis" +"salesforce" = "Salesforce" +"samba" = "Samba" +"segment" = "Segment" +"sendgrid" = "SendGrid" +"singularity" = "Singularity" +"slack" = "Slack" +"snowflake" = "Snowflake" +"standard" = "Standard" +"tableau" = "Tableau" +"telegram" = "Telegram" +"teradata" = "Teradata" +"trino" = "Trino" +"vertica" = "Vertica" +"weaviate" = "Weaviate" +"yandex" = "Yandex" +"ydb" = "YDB" +"zendesk" = "Zendesk" diff --git a/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py new file mode 100644 index 00000000..19116aa3 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/tests/test_generate_cve_json.py @@ -0,0 +1,961 @@ +# 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. +"""Unit tests for pure helpers in ``generate_cve_json``. + +Everything that shells out to ``gh`` or touches the network lives in +``fetch_issue``, ``attach_to_issue``, ``_gh_api_json``, and +``_find_existing_attachment_comment_id``. Those are excluded from this +test suite; they can be exercised against a live issue by running the +CLI end-to-end. +""" + +from __future__ import annotations + +from typing import Any + +from generate_cve_json import ( + _build_attachment_body, + _is_cna_ready_for_review, + _product_for_package, + build_affected, + build_credits, + build_metrics, + build_problem_types, + build_references, + classify_reference, + combine_remediation_developers, + compute_cna_private_state, + compute_package_url, + extract_field, + format_version_range, + parse_affected_versions, + parse_credits_from_field, + parse_cve_id, + parse_cwe, + parse_url_list, + resolve_title, + wrap_cve_record, +) + +DEFAULT_AFFECTED_ARGS: dict[str, Any] = { + "vendor": "Apache Software Foundation", + "product": "Apache Airflow", + "package_name": "apache-airflow", + "collection_url": "https://pypi.python.org", + "version_start": None, +} + +# --------------------------------------------------------------------------- +# Issue body field extraction +# --------------------------------------------------------------------------- + + +class TestExtractField: + def test_returns_value_up_to_next_field(self): + body = ( + "### The issue description\nSummary text.\n\n### Affected versions\n>=3.0.0\n\n### CWE\nCWE-352\n" + ) + assert extract_field(body, "Affected versions") == ">=3.0.0" + assert extract_field(body, "CWE") == "CWE-352" + + def test_missing_field_returns_empty_string(self): + body = "### Short public summary for publish\n\nHello.\n" + assert extract_field(body, "CVE tool link") == "" + + def test_no_response_placeholder_is_normalised_to_empty(self): + body = "### Severity\n\n_No response_\n" + assert extract_field(body, "Severity") == "" + + def test_trailing_whitespace_is_stripped(self): + body = "### Short public summary for publish\n\nHello. \n\n### Affected versions\n\n\n" + assert extract_field(body, "Short public summary for publish") == "Hello." + + def test_public_advisory_url_field_is_separate_from_security_mailing_list_thread(self): + body = ( + "### Security mailing list thread\n\n" + "https://lists.apache.org/thread/fake-security-private-hash\n\n" + "### Public advisory URL\n\n" + "https://lists.apache.org/thread/real-users-archive-id?users@airflow.apache.org\n\n" + "### Reporter credited as\n\nAlice\n" + ) + # The two fields are addressed independently by name; the private + # security@ URL stays in the body but is never exported. + assert ( + extract_field(body, "Security mailing list thread") + == "https://lists.apache.org/thread/fake-security-private-hash" + ) + assert ( + extract_field(body, "Public advisory URL") + == "https://lists.apache.org/thread/real-users-archive-id?users@airflow.apache.org" + ) + + +# --------------------------------------------------------------------------- +# Credit parsing +# --------------------------------------------------------------------------- + + +class TestParseCreditsFromField: + def test_newline_separated_credits_produce_multiple_entries(self): + value = "Alice Smith\nBob Jones (Acme Corp)" + assert parse_credits_from_field(value) == ["Alice Smith", "Bob Jones (Acme Corp)"] + + def test_comma_inside_a_credit_stays_in_the_same_credit(self): + value = "Jane Doe, Acme Security" + # Comma-splitting is deliberately off: "Name, Affiliation" is ONE credit. + assert parse_credits_from_field(value) == ["Jane Doe, Acme Security"] + + def test_bullets_are_stripped(self): + value = "- Alice\n* Bob\n+ Charlie\n1. Diana" + assert parse_credits_from_field(value) == ["Alice", "Bob", "Charlie", "Diana"] + + def test_duplicates_are_removed_preserving_order(self): + value = "Alice\nBob\nAlice" + assert parse_credits_from_field(value) == ["Alice", "Bob"] + + def test_empty_field_returns_empty_list(self): + assert parse_credits_from_field("") == [] + + +# --------------------------------------------------------------------------- +# URL list parsing +# --------------------------------------------------------------------------- + + +class TestParseUrlList: + def test_multiple_urls_extracted(self): + value = ( + "First:\nhttps://github.com/apache/airflow/pull/64114\n" + "Second: https://github.com/apache/airflow/pull/65346" + ) + urls = parse_url_list(value) + assert urls == [ + "https://github.com/apache/airflow/pull/64114", + "https://github.com/apache/airflow/pull/65346", + ] + + def test_bare_text_with_no_urls_returns_empty(self): + assert parse_url_list("No links here.") == [] + + +# --------------------------------------------------------------------------- +# CVE ID / CWE parsing +# --------------------------------------------------------------------------- + + +class TestParseCveId: + def test_extracts_cve_from_asf_tool_link(self): + assert parse_cve_id("https://cveprocess.apache.org/cve5/CVE-2026-40948") == "CVE-2026-40948" + + def test_extracts_cve_from_plain_id(self): + assert parse_cve_id("CVE-2026-40948") == "CVE-2026-40948" + + def test_missing_returns_empty(self): + assert parse_cve_id("") == "" + + +class TestParseCwe: + def test_cwe_with_title(self): + cwe_id, description = parse_cwe("CWE-352: Cross-Site Request Forgery (CSRF)") + assert cwe_id == "CWE-352" + assert description == "CWE-352: Cross-Site Request Forgery (CSRF)" + + def test_cwe_without_title_emits_bare_id_as_description(self): + cwe_id, description = parse_cwe("CWE-614") + assert cwe_id == "CWE-614" + assert description == "CWE-614" + + def test_no_cwe_keeps_original_text_as_description(self): + cwe_id, description = parse_cwe("_No response_") + assert cwe_id == "" + assert description == "_No response_" + + +# --------------------------------------------------------------------------- +# Affected-versions parsing +# --------------------------------------------------------------------------- + + +class TestParseAffectedVersions: + def test_bare_less_than_creates_range(self): + versions = parse_affected_versions("<3.2.0", None) + assert versions == [ + {"lessThan": "3.2.0", "status": "affected", "version": "0", "versionType": "semver"}, + ] + + def test_inclusive_range(self): + versions = parse_affected_versions(">=3.0.0, <3.2.0", None) + assert versions == [ + {"lessThan": "3.2.0", "status": "affected", "version": "3.0.0", "versionType": "semver"}, + ] + + def test_less_or_equal_upper_bound(self): + versions = parse_affected_versions("<=6.5.0", None) + assert versions == [ + { + "lessThanOrEqual": "6.5.0", + "status": "affected", + "version": "0", + "versionType": "semver", + }, + ] + + def test_version_start_override_sets_low_bound(self): + versions = parse_affected_versions(">=3.0.0, <3.2.0", "2.10.5") + assert versions[0]["version"] == "2.10.5" + + def test_unknown_string_falls_back_to_single_entry(self): + versions = parse_affected_versions("all versions", None) + assert versions + assert versions[0]["status"] == "affected" + + def test_lower_bound_only_emits_open_ended_entry(self): + # `>=2.0.0` (no upper) — useful for trackers whose fix-shipped + # version isn't known yet (typically providers). + versions = parse_affected_versions(">=2.0.0", None) + assert versions == [ + {"status": "affected", "version": "2.0.0", "versionType": "semver"}, + ] + + def test_lower_bound_only_with_space(self): + versions = parse_affected_versions(">= 2.0.0", None) + assert versions == [ + {"status": "affected", "version": "2.0.0", "versionType": "semver"}, + ] + + +class TestParseAffectedVersionsNextVersionPlaceholder: + """The `< NEXT VERSION` sentinel: fix not yet released, upper bound unknown. + + Stripped before further parsing; resulting entry has no `lessThan`. + Used predominantly for providers trackers where the wave milestone + is date-based and the package version that ships the fix is decided + by the release manager during the wave. + """ + + def test_just_next_version_placeholder(self): + versions = parse_affected_versions("< NEXT VERSION", None) + assert versions == [ + {"status": "affected", "version": "0", "versionType": "semver"}, + ] + + def test_lowercase_token_also_accepted(self): + versions = parse_affected_versions("< next version", None) + assert versions == [ + {"status": "affected", "version": "0", "versionType": "semver"}, + ] + + def test_lower_bound_with_next_version_upper(self): + versions = parse_affected_versions(">= 2.0.0 < NEXT VERSION", None) + assert versions == [ + {"status": "affected", "version": "2.0.0", "versionType": "semver"}, + ] + + def test_lower_bound_with_comma_and_next_version_upper(self): + versions = parse_affected_versions(">= 2.0.0, < NEXT VERSION", None) + assert versions == [ + {"status": "affected", "version": "2.0.0", "versionType": "semver"}, + ] + + def test_real_version_replacing_placeholder_round_trips(self): + # Once the release manager knows the fix-shipped version, the + # sync skill replaces `< NEXT VERSION` with `< X.Y.Z`. The + # parser must produce the standard fully-bounded shape. + versions = parse_affected_versions(">= 2.0.0, < 5.6.0", None) + assert versions == [ + {"status": "affected", "version": "2.0.0", "lessThan": "5.6.0", "versionType": "semver"}, + ] + + +# --------------------------------------------------------------------------- +# Product-name resolution for Airflow packages +# --------------------------------------------------------------------------- + + +class TestProductForPackage: + def test_core_package_resolves_to_apache_airflow(self): + assert _product_for_package("apache-airflow") == "Apache Airflow" + + def test_known_provider_uses_display_map_casing(self): + assert ( + _product_for_package("apache-airflow-providers-elasticsearch") + == "Apache Airflow Providers Elasticsearch" + ) + assert ( + _product_for_package("apache-airflow-providers-opensearch") + == "Apache Airflow Providers OpenSearch" + ) + assert ( + _product_for_package("apache-airflow-providers-cncf-kubernetes") + == "Apache Airflow Providers CNCF Kubernetes" + ) + # Confirms the user-cited mapping example from the Vulnogram form: + # apache-airflow-providers-snowflake → "Apache Airflow Providers Snowflake". + assert ( + _product_for_package("apache-airflow-providers-snowflake") == "Apache Airflow Providers Snowflake" + ) + + def test_unknown_provider_falls_back_to_title_case(self): + assert ( + _product_for_package("apache-airflow-providers-madeup-widget") + == "Apache Airflow Providers Madeup Widget" + ) + + def test_overrides_win_over_display_map(self): + assert ( + _product_for_package( + "apache-airflow-providers-elasticsearch", + product_overrides={ + "apache-airflow-providers-elasticsearch": "Custom ES Display", + }, + ) + == "Custom ES Display" + ) + + def test_overrides_win_over_title_case_fallback(self): + assert ( + _product_for_package( + "apache-airflow-providers-madeup-widget", + product_overrides={ + "apache-airflow-providers-madeup-widget": "Apache Airflow Providers WIDGET", + }, + ) + == "Apache Airflow Providers WIDGET" + ) + + +# --------------------------------------------------------------------------- +# Multi-product `build_affected` +# --------------------------------------------------------------------------- + + +class TestBuildAffectedSingleProduct: + def test_empty_field_emits_one_placeholder_entry(self): + entries = build_affected("", **DEFAULT_AFFECTED_ARGS) + assert len(entries) == 1 + assert entries[0]["packageName"] == "apache-airflow" + assert entries[0]["product"] == "Apache Airflow" + + def test_bare_version_range_uses_defaults(self): + entries = build_affected("<3.2.2", **DEFAULT_AFFECTED_ARGS) + assert len(entries) == 1 + assert entries[0]["packageName"] == "apache-airflow" + assert entries[0]["product"] == "Apache Airflow" + assert entries[0]["versions"][0]["lessThan"] == "3.2.2" + + def test_single_line_with_package_prefix_detects_provider(self): + entries = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0", + **DEFAULT_AFFECTED_ARGS, + ) + assert len(entries) == 1 + assert entries[0]["packageName"] == "apache-airflow-providers-elasticsearch" + assert entries[0]["product"] == "Apache Airflow Providers Elasticsearch" + assert entries[0]["versions"][0]["lessThanOrEqual"] == "6.5.0" + + def test_single_line_with_core_package_prefix_detects_core(self): + entries = build_affected( + "apache-airflow <3.2.2", + vendor="Apache Software Foundation", + product="IGNORED", + package_name="ignored", + collection_url="https://pypi.python.org", + version_start=None, + ) + assert len(entries) == 1 + # Package prefix wins over the default argument because the body + # has said the package name explicitly. + assert entries[0]["packageName"] == "apache-airflow" + assert entries[0]["product"] == "Apache Airflow" + + +class TestBuildAffectedMultiProduct: + def test_two_providers_produce_two_entries(self): + entries = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0", + **DEFAULT_AFFECTED_ARGS, + ) + assert len(entries) == 2 + es, os_ = entries + assert es["packageName"] == "apache-airflow-providers-elasticsearch" + assert es["product"] == "Apache Airflow Providers Elasticsearch" + assert es["versions"][0]["lessThanOrEqual"] == "6.5.0" + assert os_["packageName"] == "apache-airflow-providers-opensearch" + assert os_["product"] == "Apache Airflow Providers OpenSearch" + assert os_["versions"][0]["lessThanOrEqual"] == "1.9.0" + + def test_bullet_prefixes_are_stripped(self): + entries = build_affected( + "- apache-airflow-providers-elasticsearch <=6.5.0\n* apache-airflow-providers-opensearch <=1.9.0", + **DEFAULT_AFFECTED_ARGS, + ) + assert [e["packageName"] for e in entries] == [ + "apache-airflow-providers-elasticsearch", + "apache-airflow-providers-opensearch", + ] + + def test_blank_lines_between_entries_are_ignored(self): + entries = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0\n\napache-airflow-providers-opensearch <=1.9.0\n", + **DEFAULT_AFFECTED_ARGS, + ) + assert len(entries) == 2 + + def test_mixed_known_and_unknown_provider(self): + entries = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-brand-new <=0.1.0", + **DEFAULT_AFFECTED_ARGS, + ) + assert entries[0]["product"] == "Apache Airflow Providers Elasticsearch" + assert entries[1]["product"] == "Apache Airflow Providers Brand New" + + def test_product_overrides_applied_per_entry(self): + entries = build_affected( + "apache-airflow-providers-brand-new <=0.1.0", + product_overrides={ + "apache-airflow-providers-brand-new": "Apache Airflow Providers BRAND", + }, + **DEFAULT_AFFECTED_ARGS, + ) + assert entries[0]["product"] == "Apache Airflow Providers BRAND" + + def test_line_without_prefix_falls_back_to_defaults(self): + # A single line that is not a version range and doesn't carry a + # recognisable package prefix stays in the legacy single-entry + # path and takes the explicit product / packageName args. + entries = build_affected( + "all versions", + vendor="Apache Software Foundation", + product="Apache Airflow Helm Chart", + package_name="apache-airflow-helm-chart", + collection_url="https://airflow.apache.org/", + version_start=None, + ) + assert len(entries) == 1 + assert entries[0]["packageName"] == "apache-airflow-helm-chart" + assert entries[0]["product"] == "Apache Airflow Helm Chart" + + def test_multi_line_is_deterministic(self): + # Re-generating must produce byte-identical output, which is + # what keeps the `--attach` idempotence guarantee intact. + first = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0", + **DEFAULT_AFFECTED_ARGS, + ) + second = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0", + **DEFAULT_AFFECTED_ARGS, + ) + assert first == second + + def test_per_line_next_version_placeholder(self): + # The providers-tracker pattern: one line per affected package, + # all with `< NEXT VERSION` until the wave ships. + entries = build_affected( + "apache-airflow-providers-elasticsearch < NEXT VERSION\n" + "apache-airflow-providers-opensearch < NEXT VERSION", + **DEFAULT_AFFECTED_ARGS, + ) + assert len(entries) == 2 + es, os_ = entries + assert es["packageName"] == "apache-airflow-providers-elasticsearch" + assert es["versions"] == [ + {"status": "affected", "version": "0", "versionType": "semver"}, + ] + assert os_["packageName"] == "apache-airflow-providers-opensearch" + assert os_["versions"] == [ + {"status": "affected", "version": "0", "versionType": "semver"}, + ] + + def test_per_line_lower_bound_only(self): + # Lower-bound-only line (e.g. `apache-airflow-providers-smtp >=2.0.0`) + # — useful when affected versions are known to start at X but the + # fix-shipped version isn't known yet. + entries = build_affected( + "apache-airflow-providers-smtp >=2.0.0", + **DEFAULT_AFFECTED_ARGS, + ) + assert len(entries) == 1 + assert entries[0]["packageName"] == "apache-airflow-providers-smtp" + assert entries[0]["versions"] == [ + {"status": "affected", "version": "2.0.0", "versionType": "semver"}, + ] + + def test_per_line_lower_bound_with_next_version_upper(self): + entries = build_affected( + "apache-airflow-providers-smtp >= 2.0.0, < NEXT VERSION", + **DEFAULT_AFFECTED_ARGS, + ) + assert len(entries) == 1 + assert entries[0]["versions"] == [ + {"status": "affected", "version": "2.0.0", "versionType": "semver"}, + ] + + +# --------------------------------------------------------------------------- +# Reference tagging +# --------------------------------------------------------------------------- + + +class TestClassifyReference: + def test_github_pr_tagged_as_patch(self): + assert classify_reference("https://github.com/apache/airflow/pull/64114") == ["patch"] + + def test_github_commit_tagged_as_patch(self): + assert classify_reference("https://github.com/apache/airflow/commit/abc123") == ["patch"] + + def test_lists_apache_tagged_as_vendor_advisory(self): + assert classify_reference("https://lists.apache.org/thread/abc") == ["vendor-advisory"] + + def test_plain_doc_url_has_no_tags(self): + assert classify_reference("https://airflow.apache.org/docs/") == [] + + +class TestBuildReferences: + def test_mailing_list_field_urls_are_not_auto_included(self): + refs = build_references( + mailing_list_field="https://lists.apache.org/thread/fake-security-thread", + pr_field="https://github.com/apache/airflow/pull/64114", + ) + urls = [r["url"] for r in refs] + assert urls == ["https://github.com/apache/airflow/pull/64114"] + + def test_explicit_advisory_url_is_included(self): + refs = build_references( + mailing_list_field="", + pr_field="https://github.com/apache/airflow/pull/64114", + extra_urls=["https://lists.apache.org/thread/real-users-archive-url"], + ) + urls = {r["url"] for r in refs} + assert "https://lists.apache.org/thread/real-users-archive-url" in urls + # And it gets tagged correctly. + by_url = {r["url"]: r for r in refs} + advisory = by_url["https://lists.apache.org/thread/real-users-archive-url"] + assert advisory.get("tags") == ["vendor-advisory"] + + def test_airflow_s_and_cveprocess_urls_are_filtered_out(self): + refs = build_references( + mailing_list_field="", + pr_field="", + extra_urls=[ + "https://github.com/airflow-s/airflow-s/issues/256", + "https://cveprocess.apache.org/cve5/CVE-2026-40948", + "https://github.com/apache/airflow/pull/64114", + ], + ) + urls = [r["url"] for r in refs] + assert urls == ["https://github.com/apache/airflow/pull/64114"] + + +# --------------------------------------------------------------------------- +# Title handling +# --------------------------------------------------------------------------- + + +class TestResolveTitle: + def test_strips_apache_airflow_prefix_from_issue_title(self): + assert resolve_title("Apache Airflow: DAG auth bypass", "", None) == "DAG auth bypass" + + def test_override_wins(self): + assert resolve_title("from-issue", "summary", "my override") == "my override" + + def test_falls_back_to_summary_when_empty(self): + assert resolve_title("", "A summary. With more text.", None) == "A summary" + + +# --------------------------------------------------------------------------- +# Readiness helper + envelope state +# --------------------------------------------------------------------------- + + +def _ready_cna() -> dict: + """Build a minimal CNA container that satisfies every readiness rule.""" + return { + "title": "Example vulnerability", + "descriptions": [{"lang": "en", "value": "A description."}], + "affected": build_affected( + ">=3.0.0, <3.2.0", + vendor="Apache Software Foundation", + product="Apache Airflow", + package_name="apache-airflow", + collection_url="https://pypi.python.org", + version_start=None, + ), + "problemTypes": build_problem_types("CWE-352: CSRF"), + "metrics": build_metrics("Low"), + "credits": build_credits("Alice Smith", remediation_developers=["Bob"]), + "references": build_references( + mailing_list_field="", + pr_field="https://github.com/apache/airflow/pull/123", + ), + } + + +class TestIsCnaReadyForReview: + def test_fully_populated_cna_is_ready(self): + assert _is_cna_ready_for_review(_ready_cna(), "CVE-2026-00001") is True + + def test_missing_cve_id_blocks_review(self): + assert _is_cna_ready_for_review(_ready_cna(), "") is False + + def test_missing_title_blocks_review(self): + cna = _ready_cna() + cna["title"] = "" + assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False + + def test_missing_credit_blocks_review(self): + cna = _ready_cna() + cna["credits"] = [] + assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False + + def test_unknown_severity_blocks_review(self): + cna = _ready_cna() + cna["metrics"] = build_metrics("Unknown") + assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False + + def test_missing_cwe_blocks_review(self): + cna = _ready_cna() + cna["problemTypes"] = [] + assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False + + def test_missing_reference_blocks_review(self): + cna = _ready_cna() + cna["references"] = [] + assert _is_cna_ready_for_review(cna, "CVE-2026-00001") is False + + +class TestWrapCveRecord: + def test_cve_metadata_state_is_always_published(self): + # `cveMetadata.state` is the CVE 5.x schema field — only valid + # values are PUBLISHED / REJECTED for a submitted record. + # The generator hard-codes PUBLISHED regardless of workflow. + record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org") + assert record["cveMetadata"]["state"] == "PUBLISHED" + assert record["cveMetadata"]["cveId"] == "CVE-2026-00001" + + def test_cve_metadata_state_published_even_when_draft(self): + cna = _ready_cna() + cna["credits"] = [] # make the CNA incomplete + record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org") + # Workflow state went to DRAFT, but the CVE 5.x schema state + # stays PUBLISHED — these are different fields. + assert record["cveMetadata"]["state"] == "PUBLISHED" + + def test_ready_record_emits_review_workflow_state(self): + cna = _ready_cna() + record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org") + assert record["CNA_private"]["state"] == "REVIEW" + + def test_incomplete_record_emits_draft_workflow_state(self): + cna = _ready_cna() + cna["credits"] = [] + record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org") + assert record["CNA_private"]["state"] == "DRAFT" + + def test_vendor_advisory_reference_emits_public_workflow_state(self): + cna = _ready_cna() + cna["references"] = build_references( + mailing_list_field="", + pr_field="https://github.com/apache/airflow/pull/123", + extra_urls=[ + "https://lists.apache.org/thread/abc123xyz789", + ], + ) + record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org") + assert record["CNA_private"]["state"] == "PUBLIC" + + def test_vendor_advisory_with_incomplete_fields_still_draft(self): + # Even when the advisory URL is captured, an incomplete CNA + # stays at DRAFT — PUBLIC requires the full review-ready set. + cna = _ready_cna() + cna["credits"] = [] + cna["references"] = build_references( + mailing_list_field="", + pr_field="https://github.com/apache/airflow/pull/123", + extra_urls=[ + "https://lists.apache.org/thread/abc123xyz789", + ], + ) + record = wrap_cve_record(cna, cve_id="CVE-2026-00001", org_id="org") + assert record["CNA_private"]["state"] == "DRAFT" + + def test_envelope_carries_cna_private_block(self): + record = wrap_cve_record(_ready_cna(), cve_id="CVE-2026-00001", org_id="org") + assert record["CNA_private"]["owner"] == "airflow" + assert record["CNA_private"]["userslist"] == "users@airflow.apache.org" + + +# --------------------------------------------------------------------------- +# compute_cna_private_state +# --------------------------------------------------------------------------- + + +class TestComputeCnaPrivateState: + def test_ready_without_advisory_is_review(self): + assert compute_cna_private_state(_ready_cna(), "CVE-2026-00001") == "REVIEW" + + def test_ready_with_advisory_is_public(self): + cna = _ready_cna() + cna["references"] = build_references( + mailing_list_field="", + pr_field="https://github.com/apache/airflow/pull/123", + extra_urls=["https://lists.apache.org/thread/abc123xyz789"], + ) + assert compute_cna_private_state(cna, "CVE-2026-00001") == "PUBLIC" + + def test_incomplete_is_draft(self): + cna = _ready_cna() + cna["credits"] = [] + assert compute_cna_private_state(cna, "CVE-2026-00001") == "DRAFT" + + +# --------------------------------------------------------------------------- +# compute_package_url +# --------------------------------------------------------------------------- + + +class TestComputePackageUrl: + def test_pypi_python_org_returns_canonical_pypi_project_url(self): + assert ( + compute_package_url("https://pypi.python.org", "apache-airflow") + == "https://pypi.org/project/apache-airflow/" + ) + + def test_pypi_org_alias_also_supported(self): + assert ( + compute_package_url("https://pypi.org", "apache-airflow") + == "https://pypi.org/project/apache-airflow/" + ) + + def test_trailing_slash_is_tolerated(self): + assert ( + compute_package_url("https://pypi.python.org/", "apache-airflow-providers-elasticsearch") + == "https://pypi.org/project/apache-airflow-providers-elasticsearch/" + ) + + def test_unknown_collection_url_returns_none(self): + assert compute_package_url("https://airflow.apache.org/", "apache-airflow-helm-chart") is None + + def test_empty_inputs_return_none(self): + assert compute_package_url("", "apache-airflow") is None + assert compute_package_url("https://pypi.python.org", "") is None + + +# --------------------------------------------------------------------------- +# format_version_range +# --------------------------------------------------------------------------- + + +class TestFormatVersionRange: + def test_range_with_low_and_high(self): + versions = [{"version": "2.0.0", "lessThan": "3.2.2", "status": "affected", "versionType": "semver"}] + assert format_version_range(versions) == ">= 2.0.0, < 3.2.2" + + def test_open_lower_bound_uses_less_than_only(self): + versions = [{"version": "0", "lessThan": "3.2.2", "status": "affected", "versionType": "semver"}] + assert format_version_range(versions) == "< 3.2.2" + + def test_less_than_or_equal(self): + versions = [ + {"version": "0", "lessThanOrEqual": "3.2.1", "status": "affected", "versionType": "semver"} + ] + assert format_version_range(versions) == "<= 3.2.1" + + def test_bare_single_version(self): + versions = [{"version": "3.1.5", "status": "affected", "versionType": "semver"}] + assert format_version_range(versions) == "3.1.5" + + def test_round_trip_through_parser(self): + # parse_affected_versions(...) → format_version_range(...) should + # reconstruct the original human-readable shape. + for raw, expected in [ + (">= 2.0.0, < 3.2.2", ">= 2.0.0, < 3.2.2"), + ("< 3.2.2", "< 3.2.2"), + ("<= 3.2.1", "<= 3.2.1"), + ("3.1.5", "3.1.5"), + ]: + parsed = parse_affected_versions(raw, version_start_override=None) + assert format_version_range(parsed) == expected, raw + + def test_empty_input_returns_empty_string(self): + assert format_version_range([]) == "" + + +# --------------------------------------------------------------------------- +# _build_attachment_body — the issue-body table above the embedded JSON +# --------------------------------------------------------------------------- + + +def _ready_cna_for_attachment() -> dict: + """Same shape as _ready_cna() but with explicit packageName / + product / collectionURL on every affected[] entry — what + build_affected() actually emits in practice.""" + return _ready_cna() + + +class TestBuildAttachmentBody: + def test_metric_table_includes_title_state_and_size(self): + cna = _ready_cna_for_attachment() + body = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text='{"x": 1}', + cna=cna, + cna_private_state="REVIEW", + ) + assert "| CVE ID | `CVE-2026-00001` |" in body + assert "| Title | Example vulnerability |" in body + assert "| Vulnogram state | `REVIEW` |" in body + assert "| Affected packages | `apache-airflow` |" in body + assert "| Size | 8 bytes |" in body + + def test_no_envelope_renders_state_placeholder(self): + body = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text='{"x": 1}', + cna=_ready_cna_for_attachment(), + cna_private_state=None, + ) + assert "| Vulnogram state | — (`--no-envelope`) |" in body + + def test_per_package_table_includes_pypi_url_and_versions(self): + body = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text="{}", + cna=_ready_cna_for_attachment(), + cna_private_state="REVIEW", + ) + assert "**Packages this JSON covers:**" in body + assert "| # | Package | Product | Versions | PyPI URL |" in body + assert ( + "| 1 | `apache-airflow` | Apache Airflow | `>= 3.0.0, < 3.2.0` | |" + in body + ) + + def test_per_package_table_renders_one_row_per_affected_entry(self): + cna = _ready_cna_for_attachment() + cna["affected"] = build_affected( + "apache-airflow-providers-elasticsearch <=6.5.0\napache-airflow-providers-opensearch <=1.9.0", + vendor="Apache Software Foundation", + product="Apache Airflow", + package_name="apache-airflow", + collection_url="https://pypi.python.org", + version_start=None, + ) + body = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text="{}", + cna=cna, + cna_private_state="REVIEW", + ) + assert ( + "| 1 | `apache-airflow-providers-elasticsearch` | Apache Airflow Providers Elasticsearch " + "| `<= 6.5.0` | |" + ) in body + assert ( + "| 2 | `apache-airflow-providers-opensearch` | Apache Airflow Providers OpenSearch " + "| `<= 1.9.0` | |" + ) in body + assert ( + "| Affected packages | `apache-airflow-providers-elasticsearch`, " + "`apache-airflow-providers-opensearch` |" + ) in body + + def test_unknown_collection_url_renders_dash_in_url_column(self): + cna = _ready_cna_for_attachment() + cna["affected"] = [ + { + "vendor": "Apache Software Foundation", + "product": "Apache Airflow Helm Chart", + "packageName": "apache-airflow-helm-chart", + "collectionURL": "https://airflow.apache.org/", + "versions": [ + {"version": "0", "lessThan": "1.18.0", "status": "affected", "versionType": "semver"}, + ], + "defaultStatus": "unaffected", + } + ] + body = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text="{}", + cna=cna, + cna_private_state="REVIEW", + ) + assert "| 1 | `apache-airflow-helm-chart` | Apache Airflow Helm Chart | `< 1.18.0` | — |" in body + + def test_pipe_in_title_is_escaped(self): + cna = _ready_cna_for_attachment() + cna["title"] = "Pipe | in | title" + body = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text="{}", + cna=cna, + cna_private_state="REVIEW", + ) + assert "| Title | Pipe \\| in \\| title |" in body + + def test_output_is_deterministic(self): + first = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text='{"x": 1}', + cna=_ready_cna_for_attachment(), + cna_private_state="REVIEW", + ) + second = _build_attachment_body( + cve_id="CVE-2026-00001", + json_text='{"x": 1}', + cna=_ready_cna_for_attachment(), + cna_private_state="REVIEW", + ) + assert first == second + + +# --------------------------------------------------------------------------- +# combine_remediation_developers +# --------------------------------------------------------------------------- + + +class TestCombineRemediationDevelopers: + def test_body_field_only(self): + assert combine_remediation_developers("Alice Smith\nBob Jones", []) == [ + "Alice Smith", + "Bob Jones", + ] + + def test_cli_only(self): + assert combine_remediation_developers("", ["Alice Smith"]) == ["Alice Smith"] + + def test_body_first_then_cli(self): + assert combine_remediation_developers("Alice", ["Bob"]) == ["Alice", "Bob"] + + def test_duplicates_dropped_silently(self): + # Body says Alice, CLI also says Alice — single credit, body order wins. + assert combine_remediation_developers("Alice Smith\nBob Jones", ["Alice Smith", "Carol"]) == [ + "Alice Smith", + "Bob Jones", + "Carol", + ] + + def test_no_response_field_treated_as_empty(self): + # extract_field already collapses _No response_ to "" upstream; this + # asserts the helper doesn't choke if a caller passes "" directly. + assert combine_remediation_developers("", []) == [] + + def test_full_name_affiliation_pattern_preserved(self): + # Same parsing rule as Reporter credited as: "Name, Affiliation" is one credit. + assert combine_remediation_developers("Jed Cunningham, Astronomer", []) == [ + "Jed Cunningham, Astronomer", + ] diff --git a/tools/vulnogram/generate-cve-json/uv.lock b/tools/vulnogram/generate-cve-json/uv.lock new file mode 100644 index 00000000..9177d6b5 --- /dev/null +++ b/tools/vulnogram/generate-cve-json/uv.lock @@ -0,0 +1,264 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "generate-cve-json" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", specifier = ">=1.11" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "librt" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" }, + { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" }, + { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" }, + { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" }, + { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" }, + { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" }, + { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" }, + { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" }, + { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" }, + { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" }, + { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" }, + { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" }, + { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" }, + { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" }, + { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" }, + { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" }, + { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" }, + { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" }, + { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" }, + { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" }, + { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" }, + { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" }, + { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" }, + { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" }, + { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" }, + { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" }, + { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" }, +] + +[[package]] +name = "mypy" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" }, + { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" }, + { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" }, + { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" }, + { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" }, + { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" }, + { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" }, + { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" }, + { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" }, + { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]