feat(release): generate reproducible CycloneDX SBOM
This commit is contained in:
126
tests/test_release_sbom.py
Normal file
126
tests/test_release_sbom.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release"
|
||||||
|
if str(RELEASE_TOOLS_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(RELEASE_TOOLS_ROOT))
|
||||||
|
|
||||||
|
from govoplan_release.sbom import build_release_sbom, render_sbom, sbom_sha256, validate_sbom # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseSbomTests(unittest.TestCase):
|
||||||
|
@staticmethod
|
||||||
|
def _build_at(timestamp: datetime) -> dict:
|
||||||
|
return build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={
|
||||||
|
"version": "1",
|
||||||
|
"installed": [{"metadata": {"name": "FastAPI", "version": "0.100.0"}}],
|
||||||
|
},
|
||||||
|
npm_lock={"packages": {"": {"name": "govoplan", "version": "1.2.3"}}},
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_builds_valid_deduplicated_python_and_npm_components(self) -> None:
|
||||||
|
integrity = base64.b64encode(b"example digest").decode("ascii")
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={
|
||||||
|
"version": "1",
|
||||||
|
"installed": [
|
||||||
|
{"metadata": {"name": "FastAPI", "version": "0.100.0"}},
|
||||||
|
{"metadata": {"name": "FastAPI", "version": "0.100.0"}},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
npm_lock={
|
||||||
|
"packages": {
|
||||||
|
"": {"name": "govoplan", "version": "1.2.3"},
|
||||||
|
"node_modules/@scope/example": {
|
||||||
|
"name": "@scope/example",
|
||||||
|
"version": "4.5.6",
|
||||||
|
"integrity": f"sha512-{integrity}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_sbom(payload)
|
||||||
|
|
||||||
|
self.assertEqual(2, len(payload["components"]))
|
||||||
|
npm = next(item for item in payload["components"] if item["name"] == "@scope/example")
|
||||||
|
self.assertEqual("SHA-512", npm["hashes"][0]["alg"])
|
||||||
|
self.assertIn("pkg:npm/%40scope/example@4.5.6", npm["purl"])
|
||||||
|
self.assertEqual(
|
||||||
|
hashlib.sha256(render_sbom(payload).encode("utf-8")).hexdigest(),
|
||||||
|
sbom_sha256(payload),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_empty_component_inventory(self) -> None:
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={"version": "1", "installed": []},
|
||||||
|
npm_lock={"packages": {"": {"version": "1.2.3"}}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(ValueError, "no dependency components"):
|
||||||
|
validate_sbom(payload)
|
||||||
|
|
||||||
|
def test_fixed_timestamp_produces_identical_bytes_and_digest(self) -> None:
|
||||||
|
timestamp = datetime(2026, 7, 21, 8, 30, tzinfo=UTC)
|
||||||
|
|
||||||
|
first = self._build_at(timestamp)
|
||||||
|
second = self._build_at(timestamp)
|
||||||
|
|
||||||
|
self.assertEqual(render_sbom(first), render_sbom(second))
|
||||||
|
self.assertEqual(sbom_sha256(first), sbom_sha256(second))
|
||||||
|
|
||||||
|
def test_timestamp_changes_serial_and_digest(self) -> None:
|
||||||
|
first = self._build_at(datetime(2026, 7, 21, 8, 30, tzinfo=UTC))
|
||||||
|
second = self._build_at(datetime(2026, 7, 21, 8, 31, tzinfo=UTC))
|
||||||
|
|
||||||
|
self.assertNotEqual(first["serialNumber"], second["serialNumber"])
|
||||||
|
self.assertNotEqual(sbom_sha256(first), sbom_sha256(second))
|
||||||
|
|
||||||
|
def test_local_and_credential_bearing_distribution_urls_are_omitted(self) -> None:
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version="1.2.3",
|
||||||
|
pip_inspect={
|
||||||
|
"version": "1",
|
||||||
|
"installed": [
|
||||||
|
{
|
||||||
|
"metadata": {"name": "LocalPackage", "version": "1.0.0"},
|
||||||
|
"direct_url": {"url": "file:///private/build/govoplan-core"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metadata": {"name": "PrivatePackage", "version": "1.0.0"},
|
||||||
|
"direct_url": {"url": "https://user:secret@example.test/private.whl"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"metadata": {"name": "PublicPackage", "version": "1.0.0"},
|
||||||
|
"direct_url": {"url": "https://example.test/public.whl"},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
npm_lock={"packages": {"": {"name": "govoplan", "version": "1.2.3"}}},
|
||||||
|
timestamp=datetime(2026, 7, 21, 8, 30, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
by_name = {component["name"]: component for component in payload["components"]}
|
||||||
|
self.assertNotIn("externalReferences", by_name["LocalPackage"])
|
||||||
|
self.assertNotIn("externalReferences", by_name["PrivatePackage"])
|
||||||
|
self.assertEqual(
|
||||||
|
[{"type": "distribution", "url": "https://example.test/public.whl"}],
|
||||||
|
by_name["PublicPackage"]["externalReferences"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
112
tools/release/generate-release-sbom.py
Normal file
112
tools/release/generate-release-sbom.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate a CycloneDX SBOM from the resolved GovOPlaN release environment."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
|
||||||
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(META_ROOT / "tools" / "release"))
|
||||||
|
|
||||||
|
from govoplan_release.sbom import ( # noqa: E402
|
||||||
|
build_release_sbom,
|
||||||
|
parse_sbom_timestamp,
|
||||||
|
render_sbom,
|
||||||
|
sbom_sha256,
|
||||||
|
timestamp_from_source_date_epoch,
|
||||||
|
validate_sbom,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--python", default=sys.executable, help="Python executable for pip inspect.")
|
||||||
|
parser.add_argument("--core-root", type=Path, default=META_ROOT.parent / "govoplan-core")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=META_ROOT / "runtime" / "release-artifacts" / "govoplan-sbom.cdx.json",
|
||||||
|
)
|
||||||
|
parser.add_argument("--pip-inspect", type=Path, help="Use an existing pip inspect report instead of executing pip.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--timestamp",
|
||||||
|
help="ISO-8601 build timestamp. Defaults to SOURCE_DATE_EPOCH, then the current time.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
core_root = args.core_root.expanduser().resolve()
|
||||||
|
product_version = _project_version(core_root / "pyproject.toml")
|
||||||
|
npm_lock_path = core_root / "webui" / "package-lock.release.json"
|
||||||
|
if not npm_lock_path.exists():
|
||||||
|
parser.error(f"release npm lockfile does not exist: {npm_lock_path}")
|
||||||
|
|
||||||
|
pip_inspect = _read_json(args.pip_inspect) if args.pip_inspect else _run_pip_inspect(args.python)
|
||||||
|
npm_lock = _read_json(npm_lock_path)
|
||||||
|
try:
|
||||||
|
timestamp = _resolved_timestamp(args.timestamp)
|
||||||
|
payload = build_release_sbom(
|
||||||
|
product_version=product_version,
|
||||||
|
pip_inspect=pip_inspect,
|
||||||
|
npm_lock=npm_lock,
|
||||||
|
timestamp=timestamp,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
parser.error(str(exc))
|
||||||
|
validate_sbom(payload)
|
||||||
|
|
||||||
|
output = args.output.expanduser().resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(render_sbom(payload), encoding="utf-8")
|
||||||
|
print(f"Wrote {len(payload['components'])} components to {output}")
|
||||||
|
print(f"SHA-256: {sbom_sha256(payload)}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _run_pip_inspect(python: str) -> dict:
|
||||||
|
result = subprocess.run(
|
||||||
|
[python, "-m", "pip", "inspect", "--local"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SystemExit(f"pip inspect failed: {result.stderr.strip()}")
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_json(path: Path) -> dict:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise ValueError(f"expected a JSON object in {path}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _project_version(path: Path) -> str:
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
payload = tomllib.load(handle)
|
||||||
|
project = payload.get("project")
|
||||||
|
if not isinstance(project, dict) or not isinstance(project.get("version"), str):
|
||||||
|
raise ValueError(f"project.version is missing from {path}")
|
||||||
|
return project["version"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolved_timestamp(explicit: str | None):
|
||||||
|
if explicit is not None:
|
||||||
|
return parse_sbom_timestamp(explicit)
|
||||||
|
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
|
||||||
|
if source_date_epoch is not None:
|
||||||
|
return timestamp_from_source_date_epoch(source_date_epoch)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
235
tools/release/govoplan_release/sbom.py
Normal file
235
tools/release/govoplan_release/sbom.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""CycloneDX SBOM construction from resolved Python and npm environments."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import quote, urlsplit
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def build_release_sbom(
|
||||||
|
*,
|
||||||
|
product_version: str,
|
||||||
|
pip_inspect: dict[str, Any],
|
||||||
|
npm_lock: dict[str, Any],
|
||||||
|
timestamp: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
components = _deduplicate_components(
|
||||||
|
[
|
||||||
|
*python_components(pip_inspect),
|
||||||
|
*npm_components(npm_lock),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
root_ref = f"pkg:generic/govoplan@{quote(product_version, safe='') }"
|
||||||
|
resolved_timestamp = _normalized_timestamp(timestamp)
|
||||||
|
timestamp_text = resolved_timestamp.isoformat().replace("+00:00", "Z")
|
||||||
|
serial_seed = json.dumps(
|
||||||
|
{
|
||||||
|
"product_version": product_version,
|
||||||
|
"timestamp": timestamp_text,
|
||||||
|
"components": components,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
serial = uuid.uuid5(uuid.NAMESPACE_URL, f"https://govoplan.add-ideas.de/sbom/{serial_seed}")
|
||||||
|
return {
|
||||||
|
"$schema": "http://cyclonedx.org/schema/bom-1.6.schema.json",
|
||||||
|
"bomFormat": "CycloneDX",
|
||||||
|
"specVersion": "1.6",
|
||||||
|
"serialNumber": f"urn:uuid:{serial}",
|
||||||
|
"version": 1,
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": timestamp_text,
|
||||||
|
"lifecycles": [{"phase": "build"}],
|
||||||
|
"component": {
|
||||||
|
"type": "application",
|
||||||
|
"bom-ref": root_ref,
|
||||||
|
"group": "ADD ideas UG",
|
||||||
|
"name": "GovOPlaN",
|
||||||
|
"version": product_version,
|
||||||
|
"purl": root_ref,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"components": components,
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"ref": root_ref,
|
||||||
|
"dependsOn": [component["bom-ref"] for component in components],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def python_components(report: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
if str(report.get("version")) != "1":
|
||||||
|
raise ValueError("unsupported pip inspect report version")
|
||||||
|
installed = report.get("installed")
|
||||||
|
if not isinstance(installed, list):
|
||||||
|
raise ValueError("pip inspect report is missing installed distributions")
|
||||||
|
|
||||||
|
components: list[dict[str, Any]] = []
|
||||||
|
for item in installed:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
metadata = item.get("metadata")
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
continue
|
||||||
|
name = metadata.get("name")
|
||||||
|
version = metadata.get("version")
|
||||||
|
if not isinstance(name, str) or not isinstance(version, str):
|
||||||
|
continue
|
||||||
|
normalized = name.lower().replace("_", "-")
|
||||||
|
purl = f"pkg:pypi/{quote(normalized, safe='')}@{quote(version, safe='')}"
|
||||||
|
component: dict[str, Any] = {
|
||||||
|
"type": "library",
|
||||||
|
"bom-ref": purl,
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"purl": purl,
|
||||||
|
"scope": "required",
|
||||||
|
}
|
||||||
|
direct_url = item.get("direct_url")
|
||||||
|
if isinstance(direct_url, dict) and isinstance(direct_url.get("url"), str):
|
||||||
|
distribution_url = _public_distribution_url(direct_url["url"])
|
||||||
|
if distribution_url is not None:
|
||||||
|
component["externalReferences"] = [{"type": "distribution", "url": distribution_url}]
|
||||||
|
components.append(component)
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def npm_components(lock: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
packages = lock.get("packages")
|
||||||
|
if not isinstance(packages, dict):
|
||||||
|
raise ValueError("npm lockfile is missing the packages map")
|
||||||
|
|
||||||
|
components: list[dict[str, Any]] = []
|
||||||
|
for path, item in packages.items():
|
||||||
|
if not path or not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
name = item.get("name") or _npm_name_from_lock_path(str(path))
|
||||||
|
version = item.get("version")
|
||||||
|
if not isinstance(name, str) or not isinstance(version, str):
|
||||||
|
continue
|
||||||
|
purl = f"pkg:npm/{quote(name, safe='/')}@{quote(version, safe='')}"
|
||||||
|
component: dict[str, Any] = {
|
||||||
|
"type": "library",
|
||||||
|
"bom-ref": purl,
|
||||||
|
"name": name,
|
||||||
|
"version": version,
|
||||||
|
"purl": purl,
|
||||||
|
"scope": "optional" if item.get("dev") is True else "required",
|
||||||
|
}
|
||||||
|
hashes = _sri_hashes(item.get("integrity"))
|
||||||
|
if hashes:
|
||||||
|
component["hashes"] = hashes
|
||||||
|
resolved = item.get("resolved")
|
||||||
|
if isinstance(resolved, str):
|
||||||
|
distribution_url = _public_distribution_url(resolved)
|
||||||
|
if distribution_url is not None:
|
||||||
|
component["externalReferences"] = [{"type": "distribution", "url": distribution_url}]
|
||||||
|
components.append(component)
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def validate_sbom(payload: dict[str, Any]) -> None:
|
||||||
|
if payload.get("bomFormat") != "CycloneDX" or payload.get("specVersion") != "1.6":
|
||||||
|
raise ValueError("not a supported CycloneDX 1.6 SBOM")
|
||||||
|
metadata = payload.get("metadata")
|
||||||
|
if not isinstance(metadata, dict) or not isinstance(metadata.get("component"), dict):
|
||||||
|
raise ValueError("SBOM metadata component is missing")
|
||||||
|
components = payload.get("components")
|
||||||
|
if not isinstance(components, list) or not components:
|
||||||
|
raise ValueError("SBOM has no dependency components")
|
||||||
|
refs = [item.get("bom-ref") for item in components if isinstance(item, dict)]
|
||||||
|
if any(not isinstance(ref, str) or not ref for ref in refs):
|
||||||
|
raise ValueError("SBOM component is missing bom-ref")
|
||||||
|
if len(refs) != len(set(refs)):
|
||||||
|
raise ValueError("SBOM contains duplicate component references")
|
||||||
|
|
||||||
|
|
||||||
|
def sbom_sha256(payload: dict[str, Any]) -> str:
|
||||||
|
return hashlib.sha256(render_sbom(payload).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def render_sbom(payload: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(payload, indent=2, sort_keys=True) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp_from_source_date_epoch(value: str) -> datetime:
|
||||||
|
"""Resolve the reproducible-build timestamp convention to UTC."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
epoch = int(value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("SOURCE_DATE_EPOCH must be an integer Unix timestamp") from exc
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(epoch, tz=UTC)
|
||||||
|
except (OverflowError, OSError) as exc:
|
||||||
|
raise ValueError("SOURCE_DATE_EPOCH is outside the supported timestamp range") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_sbom_timestamp(value: str) -> datetime:
|
||||||
|
"""Parse an explicit ISO-8601 SBOM timestamp and normalize it to UTC."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("timestamp must be an ISO-8601 value with a timezone") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
raise ValueError("timestamp must include a timezone")
|
||||||
|
return parsed.astimezone(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_timestamp(value: datetime | None) -> datetime:
|
||||||
|
resolved = value or datetime.now(tz=UTC)
|
||||||
|
if resolved.tzinfo is None:
|
||||||
|
raise ValueError("SBOM timestamp must include a timezone")
|
||||||
|
return resolved.astimezone(UTC).replace(microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate_components(components: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
unique = {component["bom-ref"]: component for component in components}
|
||||||
|
return [unique[key] for key in sorted(unique)]
|
||||||
|
|
||||||
|
|
||||||
|
def _npm_name_from_lock_path(path: str) -> str:
|
||||||
|
marker = "node_modules/"
|
||||||
|
if marker not in path:
|
||||||
|
return Path(path).name
|
||||||
|
return path.rsplit(marker, 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _sri_hashes(value: object) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(value, str) or "-" not in value:
|
||||||
|
return []
|
||||||
|
algorithm, encoded = value.split("-", 1)
|
||||||
|
algorithm_name = {"sha256": "SHA-256", "sha384": "SHA-384", "sha512": "SHA-512"}.get(algorithm.lower())
|
||||||
|
if algorithm_name is None:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
content = base64.b64decode(encoded, validate=True).hex()
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return [{"alg": algorithm_name, "content": content}]
|
||||||
|
|
||||||
|
|
||||||
|
def _public_distribution_url(value: str) -> str | None:
|
||||||
|
"""Keep stable public provenance without leaking local paths or credentials."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.scheme.lower() not in {"git", "git+https", "http", "https"}:
|
||||||
|
return None
|
||||||
|
if not parsed.hostname or parsed.username is not None or parsed.password is not None:
|
||||||
|
return None
|
||||||
|
if parsed.query:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
Reference in New Issue
Block a user