From 020d71e915c15c3bb4022dac5afb033f89a6f480 Mon Sep 17 00:00:00 2001 From: Thang Luu <76550342+thangldw@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:22:24 +0900 Subject: [PATCH] fix: build SBOM from release wheel --- CHANGELOG.md | 3 + docs/releases/v2.0.2.md | 1 + docs/submission/DIRECTORY_SUBMISSION.md | 2 +- scripts/local_release.py | 57 ++++++++++-- tests/test_local_release.py | 110 ++++++++++++++++++++++++ 5 files changed, 166 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 591fbea..b33eb04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,18 +9,21 @@ - Added a deterministic synthetic retrieval-poisoning Failure Zoo specimen with explicit unsupported-citation, unsupported-claim, and forbidden-output findings. - Added checkout-free Windows verification of the published package, least-privilege CodeQL scanning, and protected-branch release gates. - Added disabled-by-default local usage evidence that records only command, exit code, version, and UTC timestamp without arguments or paths. +- Generate the CycloneDX SBOM from the built wheel in a dependency-free environment so its root version is exact and no local build path is disclosed. ### Tiếng Việt - Bổ sung mẫu synthetic retrieval poisoning xác định với các finding rõ ràng về citation không được hỗ trợ, claim không có căn cứ và output bị cấm. - Bổ sung kiểm tra Windows không checkout từ package đã public, CodeQL theo least privilege và release gate bảo vệ nhánh chính. - Bổ sung bằng chứng sử dụng local mặc định tắt, chỉ ghi command, exit code, version và UTC timestamp, không ghi arguments hoặc paths. +- Tạo CycloneDX SBOM từ wheel đã build trong môi trường không dependency để version gốc chính xác và không lộ local build path. ### 日本語 - unsupported citation、unsupported claim、forbidden output を明示する決定的な synthetic retrieval-poisoning Failure Zoo specimen を追加しました。 - 公開 package の checkout-free Windows 検証、least-privilege CodeQL、保護 branch release gate を追加しました。 - デフォルト無効で、引数や path を記録せず command、exit code、version、UTC timestamp のみを残す local usage evidence を追加しました。 +- build 済み wheel を dependency-free environment で読み取り、正確な root version と local build path を含まない CycloneDX SBOM を生成します。 ## [2.0.1] - 2026-08-23 diff --git a/docs/releases/v2.0.2.md b/docs/releases/v2.0.2.md index 6f93313..d8c9f17 100644 --- a/docs/releases/v2.0.2.md +++ b/docs/releases/v2.0.2.md @@ -6,6 +6,7 @@ RAGOps 2.0.2 hardens the public 2.0 release path without changing the offline ev - Verifies the published package on checkout-free Windows and preserves the same synthetic `BLOCK` decision and evidence verification. - Adds least-privilege CodeQL scanning and protected-main release gates for Python 3.11, 3.12, 3.13, Windows, and CodeQL. - Adds disabled-by-default local usage evidence containing only UTC timestamp, RAGOps version, top-level command, and exit code. Arguments and paths are not recorded, and no event is sent over the network. +- Builds the CycloneDX SBOM from the release wheel in a dependency-free environment, validating the exact RAGOps root version and rejecting local file URLs. The credential-free flagship demo still intentionally blocks a synthetic regression: citation coverage `1.0 -> 0.5`, citation precision `1.0 -> 0.5`, and lexical groundedness `1.0 -> 0.6`. This is reproducible synthetic evidence, not production adoption evidence. diff --git a/docs/submission/DIRECTORY_SUBMISSION.md b/docs/submission/DIRECTORY_SUBMISSION.md index 55a562c..528c404 100644 --- a/docs/submission/DIRECTORY_SUBMISSION.md +++ b/docs/submission/DIRECTORY_SUBMISSION.md @@ -63,7 +63,7 @@ No account or credential is required. Use Python 3.11+ and run the bundled wrapp ## v2.0.2 update notes -RAGOps 2.0.2 adds a synthetic retrieval-poisoning regression specimen, verified checkout-free Windows use of the published package, CodeQL and protected-branch gates, and explicit opt-in local usage evidence. It retains the 2.0 content-addressed evidence, slice-aware policy, calibration, trace-graph, dataset leakage, CI output, portable import, and blind-review contracts. The plugin remains skills-only and does not include a hosted MCP connector. The optional local API/workbench is distributed through the Python package, not as a remote ChatGPT connector. +RAGOps 2.0.2 adds a synthetic retrieval-poisoning regression specimen, verified checkout-free Windows use of the published package, CodeQL and protected-branch gates, explicit opt-in local usage evidence, and a wheel-derived SBOM with no local build paths. It retains the 2.0 content-addressed evidence, slice-aware policy, calibration, trace-graph, dataset leakage, CI output, portable import, and blind-review contracts. The plugin remains skills-only and does not include a hosted MCP connector. The optional local API/workbench is distributed through the Python package, not as a remote ChatGPT connector. ## Final portal checks diff --git a/scripts/local_release.py b/scripts/local_release.py index 9fcf335..f148e60 100644 --- a/scripts/local_release.py +++ b/scripts/local_release.py @@ -122,6 +122,56 @@ def verify_clean_install(wheel: Path, expected_version: str) -> None: run(str(python), "-m", "ragops.cli", "evidence", "verify", "--bundle", str(demo / "evidence")) +def prepare_sbom_environment(wheel: Path, venv: Path) -> Path: + run(sys.executable, "-m", "venv", "--without-pip", str(venv)) + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + site_packages = Path( + subprocess.check_output( + [str(python), "-c", "import site; print(site.getsitepackages()[0])"], + text=True, + ).strip() + ) + with zipfile.ZipFile(wheel) as archive: + archive.extractall(site_packages) + return python + + +def validate_sbom(sbom: Path, expected_version: str) -> None: + data = json.loads(sbom.read_text(encoding="utf-8")) + root = data.get("metadata", {}).get("component", {}) + if root.get("name") != "ragops" or root.get("version") != expected_version: + raise SystemExit("SBOM root component does not match package version") + if data.get("components"): + raise SystemExit("dependency-free RAGOps SBOM must not contain dependency components") + if data.get("dependencies") != [{"ref": root.get("bom-ref")}]: + raise SystemExit("dependency-free RAGOps SBOM has an unexpected dependency graph") + for component in [root, *data.get("components", [])]: + for reference in component.get("externalReferences", []): + if str(reference.get("url", "")).lower().startswith("file:"): + raise SystemExit("SBOM must not contain a local file URL") + + +def build_sbom(wheel: Path, sbom: Path, expected_version: str) -> None: + cyclonedx = environment_tool("cyclonedx-py") + if not (Path(cyclonedx).is_file() or shutil.which(cyclonedx)): + raise SystemExit("cyclonedx-py is required: python -m pip install cyclonedx-bom==7.3.0") + with tempfile.TemporaryDirectory(prefix="ragops-sbom-") as temp: + python = prepare_sbom_environment(wheel, Path(temp) / "venv") + run( + cyclonedx, + "environment", + str(python), + "--pyproject", + str(ROOT / "pyproject.toml"), + "--mc-type", + "library", + "--output-reproducible", + "--output-file", + str(sbom), + ) + validate_sbom(sbom, expected_version) + + def build_plugin_bundle(expected_version: str) -> Path: manifest = json.loads((ROOT / ".codex-plugin/plugin.json").read_text(encoding="utf-8")) if manifest.get("version") != expected_version: @@ -186,12 +236,7 @@ def verify(tag: str) -> None: verify_clean_install(wheel, version()) plugin = build_plugin_bundle(version()) sbom = DIST / f"ragops-{version()}.cdx.json" - cyclonedx = environment_tool("cyclonedx-py") - if Path(cyclonedx).is_file() or shutil.which(cyclonedx): - run(cyclonedx, "environment", sys.executable, "--output-reproducible", - "--output-file", str(sbom)) - else: - raise SystemExit("cyclonedx-py is required: python -m pip install cyclonedx-bom==7.3.0") + build_sbom(wheel, sbom, version()) manifest = checksums([*built, sbom, plugin]) evidence = { "schema": "ragops-local-release-evidence-0.1", diff --git a/tests/test_local_release.py b/tests/test_local_release.py index f6fb845..4243d49 100644 --- a/tests/test_local_release.py +++ b/tests/test_local_release.py @@ -1,6 +1,11 @@ import importlib.util +import json +import subprocess +import zipfile from pathlib import Path +import pytest + SPEC = importlib.util.spec_from_file_location("local_release", Path("scripts/local_release.py")) assert SPEC and SPEC.loader local_release = importlib.util.module_from_spec(SPEC) @@ -65,3 +70,108 @@ def test_environment_tool_prefers_active_python_scripts_directory(tmp_path, monk monkeypatch.setattr(local_release.sys, "executable", str(python)) assert local_release.environment_tool("ruff") == str(ruff) + + +def test_sbom_environment_uses_wheel_metadata_without_pip(tmp_path: Path) -> None: + wheel = tmp_path / "ragops-2.0.2-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("ragops/__init__.py", "__version__ = '2.0.2'\n") + archive.writestr( + "ragops-2.0.2.dist-info/METADATA", + "Metadata-Version: 2.1\nName: ragops\nVersion: 2.0.2\n", + ) + + python = local_release.prepare_sbom_environment(wheel, tmp_path / "sbom-venv") + result = subprocess.run( + [ + str(python), + "-c", + ( + "import importlib.metadata, importlib.util; " + "print(importlib.metadata.version('ragops')); " + "print(importlib.util.find_spec('pip') is None)" + ), + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.splitlines() == ["2.0.2", "True"] + + +def test_sbom_validation_rejects_local_build_paths(tmp_path: Path) -> None: + sbom = tmp_path / "sbom.json" + sbom.write_text( + json.dumps( + { + "metadata": { + "component": { + "bom-ref": "root-component", + "name": "ragops", + "version": "2.0.2", + "externalReferences": [ + { + "type": "distribution", + "url": "file:///private/build/ragops", + } + ], + } + }, + "components": [], + "dependencies": [{"ref": "root-component"}], + } + ), + encoding="utf-8", + ) + + with pytest.raises(SystemExit, match="local file URL"): + local_release.validate_sbom(sbom, "2.0.2") + + +def test_sbom_validation_rejects_wrong_root_version(tmp_path: Path) -> None: + sbom = tmp_path / "sbom.json" + sbom.write_text( + json.dumps( + { + "metadata": { + "component": { + "bom-ref": "root-component", + "name": "ragops", + "version": "2.0.1", + } + }, + "components": [], + "dependencies": [{"ref": "root-component"}], + } + ), + encoding="utf-8", + ) + + with pytest.raises(SystemExit, match="root component"): + local_release.validate_sbom(sbom, "2.0.2") + + +def test_sbom_validation_accepts_dependency_free_release(tmp_path: Path) -> None: + sbom = tmp_path / "sbom.json" + sbom.write_text( + json.dumps( + { + "metadata": { + "component": { + "bom-ref": "root-component", + "name": "ragops", + "version": "2.0.2", + "externalReferences": [ + {"type": "website", "url": "https://github.com/thangldw/ragops"} + ], + } + }, + "components": [], + "dependencies": [{"ref": "root-component"}], + } + ), + encoding="utf-8", + ) + + local_release.validate_sbom(sbom, "2.0.2")