From 598338c6e911baea3f088f00fab2a6256ab54f8c Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 5 Mar 2026 15:17:27 +0300 Subject: [PATCH 01/40] feat: add libtea dependency for TEA integration Add libtea>=0.4.0.dev17 from TestPyPI as a dependency. Configure uv to use TestPyPI as an explicit index for libtea. --- pyproject.toml | 8 ++ sbomify_action/cli/tea.py | 13 +++ tests/test_tea_cli.py | 47 ++++++++++ uv.lock | 174 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 sbomify_action/cli/tea.py create mode 100644 tests/test_tea_cli.py diff --git a/pyproject.toml b/pyproject.toml index a48e2d16..f80584b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "conan>=2.0,<3", "questionary>=2.0.1,<3", "pipdeptree>=2.0.0", + "libtea>=0.4.0.dev17", ] [project.urls] @@ -106,6 +107,13 @@ exclude = [ "docs/", ] +[[tool.uv.index]] +name = "testpypi" +url = "https://test.pypi.org/simple/" +explicit = true + +[tool.uv.sources] +libtea = { index = "testpypi" } [tool.ruff] line-length = 120 diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py new file mode 100644 index 00000000..4158c767 --- /dev/null +++ b/sbomify_action/cli/tea.py @@ -0,0 +1,13 @@ +"""TEA (Transparency Exchange API) CLI subcommand group. + +Re-exports libtea's CLI as ``sbomify-action tea``, providing access to +TEA server discovery, search, inspect, download, and conformance testing +directly from the sbomify-action CLI. + +All subcommands accept --base-url/--domain for server selection, +--token/--auth for authentication, and --json for machine-readable output. +""" + +from libtea.cli import app as tea_group + +__all__ = ["tea_group"] diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py new file mode 100644 index 00000000..e53e9e48 --- /dev/null +++ b/tests/test_tea_cli.py @@ -0,0 +1,47 @@ +"""Tests for the TEA CLI subcommand group.""" + +import unittest + +from click.testing import CliRunner + +from sbomify_action.cli.main import cli + + +class TestTeaGroup(unittest.TestCase): + """Test that the tea subcommand group is registered.""" + + def setUp(self): + self.runner = CliRunner() + + def test_tea_help(self): + """tea --help should show the TEA CLI help text.""" + result = self.runner.invoke(cli, ["tea", "--help"]) + assert result.exit_code == 0 + assert "TEA" in result.output or "tea" in result.output.lower() + + def test_tea_discover_help(self): + """tea discover --help should show discover subcommand help.""" + result = self.runner.invoke(cli, ["tea", "discover", "--help"]) + assert result.exit_code == 0 + assert "TEI" in result.output or "tei" in result.output.lower() + + def test_tea_conformance_help(self): + """tea conformance --help should show conformance subcommand help.""" + result = self.runner.invoke(cli, ["tea", "conformance", "--help"]) + assert result.exit_code == 0 + assert "conformance" in result.output.lower() + + def test_tea_search_products_help(self): + """tea search-products --help should be available.""" + result = self.runner.invoke(cli, ["tea", "search-products", "--help"]) + assert result.exit_code == 0 + + def test_tea_inspect_help(self): + """tea inspect --help should be available.""" + result = self.runner.invoke(cli, ["tea", "inspect", "--help"]) + assert result.exit_code == 0 + + def test_tea_download_help(self): + """tea download --help should be available.""" + result = self.runner.invoke(cli, ["tea", "download", "--help"]) + assert result.exit_code == 0 diff --git a/uv.lock b/uv.lock index 287adab6..ca6cb39c 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "arrow" version = "1.4.0" @@ -209,7 +218,7 @@ wheels = [ [[package]] name = "conan" -version = "2.26.1" +version = "2.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, @@ -222,7 +231,7 @@ dependencies = [ { name = "requests" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/d0/8a33f18c9010f78bb9c57772fd0cfd2ee31606017462543524ea6ac97819/conan-2.26.1.tar.gz", hash = "sha256:5692b1d8badd516878354e9e6134f4df65cf119285b86652e9f3eb810e20838f", size = 567974, upload-time = "2026-02-27T13:16:59.81Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/84/1032763b9fae2de2d32213470f829ece7467bb32ad8b19a9dfd9c77be54d/conan-2.26.2.tar.gz", hash = "sha256:28bfbbd276935623f1b304811335acb9d2b8ce3a57aa649e432d10d4f51ce055", size = 567729, upload-time = "2026-03-05T09:31:30.75Z" } [[package]] name = "coverage" @@ -486,11 +495,11 @@ wheels = [ [[package]] name = "identify" -version = "2.6.16" +version = "2.6.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/8d/e8b97e6bd3fb6fb271346f7981362f1e04d6a7463abd0de79e1fda17c067/identify-2.6.16.tar.gz", hash = "sha256:846857203b5511bbe94d5a352a48ef2359532bc8f6727b5544077a0dcfb24980", size = 99360, upload-time = "2026-01-12T18:58:58.201Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/58/40fbbcefeda82364720eba5cf2270f98496bdfa19ea75b4cccae79c698e6/identify-2.6.16-py2.py3-none-any.whl", hash = "sha256:391ee4d77741d994189522896270b787aed8670389bfd60f326d677d64a6dfb0", size = 99202, upload-time = "2026-01-12T18:58:56.627Z" }, + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, ] [[package]] @@ -741,6 +750,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] +[[package]] +name = "libtea" +version = "0.4.0.dev17" +source = { registry = "https://test.pypi.org/simple/" } +dependencies = [ + { name = "pydantic" }, + { name = "requests" }, + { name = "semver" }, +] +sdist = { url = "https://test-files.pythonhosted.org/packages/27/ee/3614c1fa0f32ca6a5614c061e30cad6239417ac35146470924f7f947a18e/libtea-0.4.0.dev17.tar.gz", hash = "sha256:217df94cb112799b596db99522894c4be474477ea35fc390d4180717b1cb4955", size = 60450, upload-time = "2026-03-05T08:58:27.923Z" } +wheels = [ + { url = "https://test-files.pythonhosted.org/packages/10/57/6fe614787a85799aa276d5358f38ee79406a069e1bb75c7f7904860ab813/libtea-0.4.0.dev17-py3-none-any.whl", hash = "sha256:3b5dcada5b3a145a4769a857cfabc4cf56b21c914bcecd6cc568149d27bd44e5", size = 67788, upload-time = "2026-03-05T08:58:26.358Z" }, +] + [[package]] name = "license-expression" version = "30.4.4" @@ -1194,6 +1217,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1619,6 +1754,7 @@ dependencies = [ { name = "conan" }, { name = "cyclonedx-bom" }, { name = "cyclonedx-python-lib" }, + { name = "libtea" }, { name = "packageurl-python" }, { name = "pipdeptree" }, { name = "questionary" }, @@ -1650,6 +1786,7 @@ requires-dist = [ { name = "conan", specifier = ">=2.0,<3" }, { name = "cyclonedx-bom", specifier = ">=7.2.1,<8" }, { name = "cyclonedx-python-lib", specifier = ">=11.5.0,<12" }, + { name = "libtea", specifier = ">=0.4.0.dev17", index = "https://test.pypi.org/simple/" }, { name = "packageurl-python", specifier = ">=0.17.6" }, { name = "pipdeptree", specifier = ">=2.0.0" }, { name = "questionary", specifier = ">=2.0.1,<3" }, @@ -1697,17 +1834,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" }, ] +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + [[package]] name = "sentry-sdk" -version = "2.53.0" +version = "2.54.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/06/66c8b705179bc54087845f28fd1b72f83751b6e9a195628e2e9af9926505/sentry_sdk-2.53.0.tar.gz", hash = "sha256:6520ef2c4acd823f28efc55e43eb6ce2e6d9f954a95a3aa96b6fd14871e92b77", size = 412369, upload-time = "2026-02-16T11:11:14.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/47/d4/2fdf854bc3b9c7f55219678f812600a20a138af2dd847d99004994eada8f/sentry_sdk-2.53.0-py2.py3-none-any.whl", hash = "sha256:46e1ed8d84355ae54406c924f6b290c3d61f4048625989a723fd622aab838899", size = 437908, upload-time = "2026-02-16T11:11:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, ] [[package]] @@ -1861,6 +2007,18 @@ 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" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.3" From e23f243432279ae583c5206676daeff21e6df382 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 5 Mar 2026 15:24:58 +0300 Subject: [PATCH 02/40] feat: register tea subcommand group in CLI Wire libtea's CLI group into sbomify-action as 'sbomify-action tea', providing discover, search, inspect, download, and conformance commands. --- sbomify_action/cli/main.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/sbomify_action/cli/main.py b/sbomify_action/cli/main.py index 8a8be492..5eabf7c5 100644 --- a/sbomify_action/cli/main.py +++ b/sbomify_action/cli/main.py @@ -312,6 +312,7 @@ def validate(self) -> None: if not isinstance(product_releases_list, list): raise ConfigurationError('PRODUCT_RELEASE must be a JSON list like ["product_id:v1.2.3"]') + validated_releases = [] for release in product_releases_list: if not isinstance(release, str) or ":" not in release: raise ConfigurationError( @@ -319,18 +320,23 @@ def validate(self) -> None: ) product_id, version = release.split(":", 1) + product_id = product_id.strip() + version = version.strip() + # Validate that product_id looks like a proper ID (not empty) - if not product_id.strip(): + if not product_id: raise ConfigurationError( f"Invalid product_id in PRODUCT_RELEASE: '{release}'. Product ID cannot be empty." ) - if not version.strip(): + if not version: raise ConfigurationError( f"Invalid version in PRODUCT_RELEASE: '{release}'. Version cannot be empty." ) - # Store the parsed list back for later use - self.product_releases = product_releases_list + validated_releases.append(f"{product_id}:{version}") + + # Store the parsed and cleaned list back for later use + self.product_releases = validated_releases logger.info(f"Validated product releases: {self.product_releases}") except json.JSONDecodeError as e: @@ -633,7 +639,7 @@ def initialize_sentry() -> None: logger.debug("Sentry telemetry disabled via TELEMETRY environment variable") return - sentry_dsn = os.getenv("SENTRY_DSN", "https://84e8d6d0a7d0872a4bba8add571a554c@sentry.vikpire.com/4") + sentry_dsn = os.getenv("SENTRY_DSN", "") def before_send(event: dict[str, Any], hint: dict[str, Any]) -> dict[str, Any] | None: """ @@ -2341,6 +2347,11 @@ def init_cmd(output: str) -> None: sys.exit(run_wizard(output)) +from .tea import tea_group + +cli.add_command(tea_group, "tea") + + def main() -> None: """Main entry point for the sbomify action. From 3f10b9346f5764302c01765e9a7a3ad36ab6378f Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 5 Mar 2026 15:33:40 +0300 Subject: [PATCH 03/40] feat: add 'tea fetch' convenience command Single-step SBOM fetching: TEI discovery -> collection lookup -> BOM artifact selection -> download with checksum verification. --- sbomify_action/cli/tea.py | 131 +++++++++++++++++++++++++++++++-- tests/test_tea_cli.py | 151 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 276 insertions(+), 6 deletions(-) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 4158c767..68e4991d 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -1,13 +1,132 @@ """TEA (Transparency Exchange API) CLI subcommand group. -Re-exports libtea's CLI as ``sbomify-action tea``, providing access to -TEA server discovery, search, inspect, download, and conformance testing -directly from the sbomify-action CLI. - -All subcommands accept --base-url/--domain for server selection, ---token/--auth for authentication, and --json for machine-readable output. +Re-exports libtea's CLI as ``sbomify-action tea``, plus a custom ``fetch`` +command that combines discovery + collection lookup + artifact download. """ +import sys +from pathlib import Path + +import click +from libtea.cli import _build_client, _error from libtea.cli import app as tea_group +from libtea.exceptions import TeaError +from libtea.models import ArtifactType __all__ = ["tea_group"] + +_BOM_MEDIA_TYPES = [ + "application/vnd.cyclonedx+json", + "application/spdx+json", + "application/json", +] + + +def _select_best_format(formats, preferred_media_types=_BOM_MEDIA_TYPES): + """Select the best artifact format by media type preference.""" + for preferred in preferred_media_types: + for fmt in formats: + if fmt.media_type and preferred in fmt.media_type: + return fmt + for fmt in formats: + if fmt.url: + return fmt + return None + + +@tea_group.command() +@click.option("--tei", default=None, help="TEI URN to discover and fetch SBOM for") +@click.option("--product-release-uuid", default=None, help="Product release UUID to fetch from") +@click.option("--component-release-uuid", default=None, help="Component release UUID to fetch from") +@click.option( + "--artifact-type", + type=click.Choice([t.value for t in ArtifactType], case_sensitive=False), + default=ArtifactType.BOM.value, + help="Artifact type to download (default: BOM)", +) +@click.option("-o", "--output", "output_path", required=True, type=click.Path(), help="Output file path") +@click.option("--base-url", envvar="TEA_BASE_URL", default=None, help="TEA server base URL") +@click.option("--domain", default=None, help="Domain for .well-known/tea discovery") +@click.option("--token", envvar="TEA_TOKEN", default=None, help="Bearer token") +@click.option("--auth", envvar="TEA_AUTH", default=None, help="Basic auth as USER:PASSWORD") +@click.option("--timeout", type=click.FloatRange(min=0.1), default=30.0, help="Request timeout") +@click.option("--use-http", is_flag=True, help="Use HTTP instead of HTTPS") +@click.option("--port", type=int, default=None, help="Port for well-known resolution") +@click.option("--allow-private-ips", is_flag=True, help="Allow private IPs") +def fetch( + tei, + product_release_uuid, + component_release_uuid, + artifact_type, + output_path, + base_url, + domain, + token, + auth, + timeout, + use_http, + port, + allow_private_ips, +): + """Fetch an SBOM from a TEA server in one step. + + Combines discovery, collection lookup, artifact selection, and download. + Provide --tei for automatic discovery or --product-release-uuid / + --component-release-uuid for direct lookup. + + \b + Examples: + sbomify-action tea fetch --tei "urn:tei:purl:example.com:pkg:pypi/requests@2.31" -o sbom.json + sbomify-action tea fetch --product-release-uuid abc-123 -o sbom.json --base-url https://tea.example.com/v1 + """ + if not tei and not product_release_uuid and not component_release_uuid: + _error("Must specify --tei, --product-release-uuid, or --component-release-uuid") + + target_type = ArtifactType(artifact_type) + dest = Path(output_path) + + try: + with _build_client( + base_url, token, domain, timeout, use_http, port, auth, tei=tei, allow_private_ips=allow_private_ips + ) as client: + pr_uuid = product_release_uuid + cr_uuid = component_release_uuid + + if tei and not pr_uuid and not cr_uuid: + discoveries = client.discover(tei) + if not discoveries: + _error(f"No product releases found for TEI: {tei}") + pr_uuid = discoveries[0].product_release_uuid + print(f"Discovered product release: {pr_uuid}", file=sys.stderr) + + if pr_uuid: + collection = client.get_product_release_collection_latest(pr_uuid) + elif cr_uuid: + collection = client.get_component_release_collection_latest(cr_uuid) + else: + _error("Internal error: no UUID resolved") + + matching = [a for a in collection.artifacts if a.type == target_type] + if not matching: + available = {a.type.value for a in collection.artifacts if a.type} + _error( + f"No {target_type.value} artifact found. Available types: {', '.join(sorted(available)) or 'none'}" + ) + + artifact = matching[0] + if not artifact.formats: + _error(f"Artifact '{artifact.name}' has no downloadable formats") + + fmt = _select_best_format(artifact.formats) + if not fmt or not fmt.url: + _error(f"No downloadable format found for artifact '{artifact.name}'") + + print(f"Downloading {artifact.name} ({fmt.media_type}) ...", file=sys.stderr) + + result_path = client.download_artifact(fmt.url, dest, verify_checksums=fmt.checksums) + print(f"Saved to {result_path}", file=sys.stderr) + + except TeaError as exc: + _error(str(exc)) + except OSError as exc: + _error(f"I/O error: {exc}") diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index e53e9e48..c431e689 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -1,6 +1,7 @@ """Tests for the TEA CLI subcommand group.""" import unittest +from unittest.mock import MagicMock, patch from click.testing import CliRunner @@ -45,3 +46,153 @@ def test_tea_download_help(self): """tea download --help should be available.""" result = self.runner.invoke(cli, ["tea", "download", "--help"]) assert result.exit_code == 0 + + +class TestTeaFetch(unittest.TestCase): + """Test the custom fetch convenience command.""" + + def setUp(self): + self.runner = CliRunner() + + def test_fetch_help(self): + """tea fetch --help should show fetch subcommand help.""" + result = self.runner.invoke(cli, ["tea", "fetch", "--help"]) + assert result.exit_code == 0 + assert "fetch" in result.output.lower() or "SBOM" in result.output + + def test_fetch_requires_identifier(self): + """tea fetch should fail if no --tei or --product-release-uuid given.""" + result = self.runner.invoke( + cli, + ["tea", "fetch", "--base-url", "https://tea.example.com/v1", "-o", "sbom.json"], + ) + assert result.exit_code != 0 + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_by_tei(self, mock_build_client): + """tea fetch --tei should discover, find BOM, and download.""" + from libtea.models import ( + Artifact, + ArtifactFormat, + ArtifactType, + Collection, + CollectionBelongsTo, + DiscoveryInfo, + TeaServerInfo, + ) + + mock_client = MagicMock() + mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_build_client.return_value.__exit__ = MagicMock(return_value=False) + + mock_client.discover.return_value = [ + DiscoveryInfo( + product_release_uuid="pr-uuid-1", + servers=[TeaServerInfo(root_url="https://tea.example.com/v1", versions=["0.3.0-beta.2"], priority=1.0)], + ) + ] + mock_client.get_product_release_collection_latest.return_value = Collection( + uuid="col-uuid", + version=1, + date=None, + belongs_to=CollectionBelongsTo.PRODUCT_RELEASE, + update_reason=None, + artifacts=[ + Artifact( + uuid="art-uuid", + name="sbom", + type=ArtifactType.BOM, + distribution_types=None, + formats=[ + ArtifactFormat( + media_type="application/vnd.cyclonedx+json", + description=None, + url="https://cdn.example.com/sbom.json", + signature_url=None, + ) + ], + ) + ], + ) + mock_client.download_artifact.return_value = "/tmp/sbom.json" + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:purl:example.com:pkg:pypi/lib@1.0", + "-o", + "sbom.json", + ], + ) + assert result.exit_code == 0, f"Failed with: {result.output}" + mock_client.discover.assert_called_once() + mock_client.download_artifact.assert_called_once() + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_no_bom_artifact(self, mock_build_client): + """tea fetch should error when no BOM artifact found.""" + from libtea.models import ( + Artifact, + ArtifactFormat, + ArtifactType, + Collection, + CollectionBelongsTo, + DiscoveryInfo, + TeaServerInfo, + ) + + mock_client = MagicMock() + mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_build_client.return_value.__exit__ = MagicMock(return_value=False) + + mock_client.discover.return_value = [ + DiscoveryInfo( + product_release_uuid="pr-uuid-1", + servers=[TeaServerInfo(root_url="https://tea.example.com/v1", versions=["0.3.0-beta.2"], priority=1.0)], + ) + ] + mock_client.get_product_release_collection_latest.return_value = Collection( + uuid="col-uuid", + version=1, + date=None, + belongs_to=CollectionBelongsTo.PRODUCT_RELEASE, + update_reason=None, + artifacts=[ + Artifact( + uuid="art-uuid", + name="vex", + type=ArtifactType.VULNERABILITIES, + distribution_types=None, + formats=[ + ArtifactFormat( + media_type="application/json", + description=None, + url="https://cdn.example.com/vex.json", + signature_url=None, + ) + ], + ) + ], + ) + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:purl:example.com:pkg:pypi/lib@1.0", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 From de7d6bcded9fb796f51e4293762827c415804049 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 5 Mar 2026 15:37:51 +0300 Subject: [PATCH 04/40] fix: move tea_group import to top of file to satisfy E402 lint rule --- sbomify_action/cli/main.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sbomify_action/cli/main.py b/sbomify_action/cli/main.py index 5eabf7c5..6a391f27 100644 --- a/sbomify_action/cli/main.py +++ b/sbomify_action/cli/main.py @@ -56,6 +56,7 @@ ) from ..spdx3 import is_spdx3 from ..upload import upload_sbom +from .tea import tea_group # Import version for tool metadata with multiple fallback mechanisms @@ -2347,8 +2348,6 @@ def init_cmd(output: str) -> None: sys.exit(run_wizard(output)) -from .tea import tea_group - cli.add_command(tea_group, "tea") From 19b04f6be0ad7b833604fbaefdea6fc83a192842 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 5 Mar 2026 16:31:50 +0300 Subject: [PATCH 05/40] fix: address code review findings for TEA CLI - Document private API coupling to libtea (_build_client, _error) - Use tuple for _BOM_MEDIA_TYPES to prevent mutation - Add test for --component-release-uuid path with checksum verification - Add unit tests for _select_best_format edge cases - Fix import ordering --- sbomify_action/cli/tea.py | 8 ++- tests/test_tea_cli.py | 101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 68e4991d..9cb1dfb5 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -8,6 +8,10 @@ from pathlib import Path import click + +# _build_client and _error are private helpers in libtea's CLI module. +# We own libtea and intentionally couple to these; they are stable. +# _error() is typed NoReturn and raises SystemExit(1). from libtea.cli import _build_client, _error from libtea.cli import app as tea_group from libtea.exceptions import TeaError @@ -15,11 +19,11 @@ __all__ = ["tea_group"] -_BOM_MEDIA_TYPES = [ +_BOM_MEDIA_TYPES = ( "application/vnd.cyclonedx+json", "application/spdx+json", "application/json", -] +) def _select_best_format(formats, preferred_media_types=_BOM_MEDIA_TYPES): diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index c431e689..cfda9b73 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -1,11 +1,13 @@ """Tests for the TEA CLI subcommand group.""" import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from click.testing import CliRunner from sbomify_action.cli.main import cli +from sbomify_action.cli.tea import _select_best_format class TestTeaGroup(unittest.TestCase): @@ -196,3 +198,102 @@ def test_fetch_no_bom_artifact(self, mock_build_client): ], ) assert result.exit_code != 0 + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_by_component_release_uuid(self, mock_build_client): + """tea fetch --component-release-uuid should fetch without discovery.""" + from libtea.models import ( + Artifact, + ArtifactFormat, + ArtifactType, + Checksum, + ChecksumAlgorithm, + Collection, + CollectionBelongsTo, + ) + + mock_client = MagicMock() + mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_build_client.return_value.__exit__ = MagicMock(return_value=False) + + checksums = (Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="abc123"),) + mock_client.get_component_release_collection_latest.return_value = Collection( + uuid="col-uuid", + version=1, + date=None, + belongs_to=CollectionBelongsTo.COMPONENT_RELEASE, + update_reason=None, + artifacts=[ + Artifact( + uuid="art-uuid", + name="sbom", + type=ArtifactType.BOM, + distribution_types=None, + formats=[ + ArtifactFormat( + media_type="application/vnd.cyclonedx+json", + description=None, + url="https://cdn.example.com/sbom.json", + signature_url=None, + checksums=checksums, + ) + ], + ) + ], + ) + mock_client.download_artifact.return_value = Path("/tmp/sbom.json") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--component-release-uuid", + "cr-uuid-1", + "-o", + "sbom.json", + ], + ) + assert result.exit_code == 0, f"Failed with: {result.output}" + mock_client.discover.assert_not_called() + mock_client.get_component_release_collection_latest.assert_called_once_with("cr-uuid-1") + mock_client.download_artifact.assert_called_once() + # Verify checksums were passed through + call_kwargs = mock_client.download_artifact.call_args + assert call_kwargs.kwargs.get("verify_checksums") == checksums + + +class TestSelectBestFormat(unittest.TestCase): + """Test the _select_best_format helper.""" + + def _make_fmt(self, media_type=None, url=None): + return MagicMock(media_type=media_type, url=url) + + def test_prefers_cyclonedx(self): + spdx = self._make_fmt("application/spdx+json", "https://a.com/spdx.json") + cdx = self._make_fmt("application/vnd.cyclonedx+json", "https://a.com/cdx.json") + assert _select_best_format([spdx, cdx]) is cdx + + def test_prefers_spdx_over_generic(self): + generic = self._make_fmt("application/json", "https://a.com/generic.json") + spdx = self._make_fmt("application/spdx+json", "https://a.com/spdx.json") + assert _select_best_format([generic, spdx]) is spdx + + def test_falls_back_to_url(self): + unknown = self._make_fmt("application/xml", "https://a.com/sbom.xml") + assert _select_best_format([unknown]) is unknown + + def test_skips_format_without_url(self): + no_url = self._make_fmt("application/xml", None) + with_url = self._make_fmt("text/plain", "https://a.com/file") + assert _select_best_format([no_url, with_url]) is with_url + + def test_returns_none_for_empty(self): + assert _select_best_format([]) is None + + def test_returns_none_when_no_url(self): + no_url = self._make_fmt("application/xml", None) + assert _select_best_format([no_url]) is None From b54fe622f239223e80f3de9092d5da4e0398589f Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Thu, 5 Mar 2026 16:58:03 +0300 Subject: [PATCH 06/40] fix: restore Sentry DSN default --- sbomify_action/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sbomify_action/cli/main.py b/sbomify_action/cli/main.py index 6a391f27..9934ba7d 100644 --- a/sbomify_action/cli/main.py +++ b/sbomify_action/cli/main.py @@ -640,7 +640,7 @@ def initialize_sentry() -> None: logger.debug("Sentry telemetry disabled via TELEMETRY environment variable") return - sentry_dsn = os.getenv("SENTRY_DSN", "") + sentry_dsn = os.getenv("SENTRY_DSN", "https://84e8d6d0a7d0872a4bba8add571a554c@sentry.vikpire.com/4") def before_send(event: dict[str, Any], hint: dict[str, Any]) -> dict[str, Any] | None: """ From 6adbf748534167db0bf41923a613adf2015d3e22 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Fri, 6 Mar 2026 20:05:34 +0300 Subject: [PATCH 07/40] feat: add TEA enrichment source with auto-discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries TEA servers for CLE lifecycle data (end-of-support, end-of-life, release dates) and license info during SBOM enrichment. Uses PURL type to domain mapping (e.g. pypi → pypi.sbomify.com) to auto-discover TEA servers via .well-known/tea. TEA_BASE_URL env var overrides auto-discovery for all PURL types. Registered at priority 45 (Tier 2 aggregator) in the enrichment registry. --- sbomify_action/_enrichment/enricher.py | 5 + .../_enrichment/sources/__init__.py | 2 + sbomify_action/_enrichment/sources/tea.py | 184 ++++++++++ tests/test_tea_enrichment.py | 347 ++++++++++++++++++ 4 files changed, 538 insertions(+) create mode 100644 sbomify_action/_enrichment/sources/tea.py create mode 100644 tests/test_tea_enrichment.py diff --git a/sbomify_action/_enrichment/enricher.py b/sbomify_action/_enrichment/enricher.py index ea8c802b..a4ef01ca 100644 --- a/sbomify_action/_enrichment/enricher.py +++ b/sbomify_action/_enrichment/enricher.py @@ -32,6 +32,7 @@ PURLSource, PyPISource, RepologySource, + TeaSource, ) @@ -57,6 +58,7 @@ def create_default_registry() -> SourceRegistry: Tier 2 - Primary Aggregators (40-49): - DepsDevSource (40) - Google Open Source Insights + - TeaSource (45) - TEA server (opt-in, requires TEA_BASE_URL or TEA_DOMAIN) - EcosystemsSource (45) - ecosyste.ms multi-ecosystem aggregator Tier 3 - Fallback Sources (70-99): @@ -80,6 +82,7 @@ def create_default_registry() -> SourceRegistry: registry.register(ConanSource()) registry.register(DebianSource()) registry.register(DepsDevSource()) + registry.register(TeaSource()) registry.register(EcosystemsSource()) registry.register(PURLSource()) registry.register(ClearlyDefinedSource()) @@ -271,6 +274,7 @@ def clear_all_caches() -> None: from .sources.pubdev import clear_cache as clear_pubdev from .sources.pypi import clear_cache as clear_pypi from .sources.repology import clear_cache as clear_repology + from .sources.tea import clear_cache as clear_tea clear_license_db() clear_lifecycle() @@ -280,6 +284,7 @@ def clear_all_caches() -> None: clear_conan() clear_debian() clear_depsdev() + clear_tea() clear_ecosystems() clear_clearlydefined() clear_repology() diff --git a/sbomify_action/_enrichment/sources/__init__.py b/sbomify_action/_enrichment/sources/__init__.py index 5f2a1680..b1564541 100644 --- a/sbomify_action/_enrichment/sources/__init__.py +++ b/sbomify_action/_enrichment/sources/__init__.py @@ -12,6 +12,7 @@ from .purl import PURLSource from .pypi import PyPISource from .repology import RepologySource +from .tea import TeaSource __all__ = [ "ClearlyDefinedSource", @@ -26,4 +27,5 @@ "PURLSource", "PyPISource", "RepologySource", + "TeaSource", ] diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py new file mode 100644 index 00000000..e810c750 --- /dev/null +++ b/sbomify_action/_enrichment/sources/tea.py @@ -0,0 +1,184 @@ +"""TEA (Transparency Exchange API) enrichment source. + +Queries TEA servers for product metadata and CLE (Common Lifecycle Enumeration) +data using TEI auto-discovery from PURL type. + +Each PURL type maps to a known TEA domain (e.g. ``pypi`` → ``pypi.sbomify.com``). +The source builds a TEI URN, discovers the TEA server via ``.well-known/tea``, +and fetches CLE lifecycle data. + +``TEA_BASE_URL`` env var overrides auto-discovery for all PURL types. + +Provides: +- Release date, end-of-support, end-of-life from CLE events +- License information from CLE ``released`` events +- Product name as supplier context + +Priority 45 (Tier 2 aggregator). +""" + +import os + +import requests +from libtea import TeaClient +from libtea.exceptions import TeaError, TeaNotFoundError +from libtea.models import CLEEventType +from packageurl import PackageURL + +from sbomify_action.logging_config import logger + +from ..metadata import NormalizedMetadata + +# PURL type → TEA domain for .well-known/tea discovery. +# Expand as sbomify indexes more ecosystems. +PURL_TYPE_TO_TEA_DOMAIN: dict[str, str] = { + "pypi": "pypi.sbomify.com", +} + +_cache: dict[str, NormalizedMetadata | None] = {} + +DEFAULT_TIMEOUT = 15 + + +def clear_cache() -> None: + """Clear the module-level cache.""" + _cache.clear() + + +def _build_tei(purl: PackageURL, domain: str) -> str: + """Build a TEI URN from a PURL and domain. + + Format: ``urn:tei:purl:{domain}:{purl_string}`` + """ + purl_str = _purl_to_search_value(purl) + return f"urn:tei:purl:{domain}:{purl_str}" + + +def _purl_to_search_value(purl: PackageURL) -> str: + """Convert a PackageURL to a canonical string (no qualifiers/subpath).""" + parts = [f"pkg:{purl.type}/"] + if purl.namespace: + parts.append(f"{purl.namespace}/") + parts.append(purl.name) + if purl.version: + parts.append(f"@{purl.version}") + return "".join(parts) + + +class TeaSource: + """Enrichment source that discovers TEA servers and fetches CLE data.""" + + @property + def name(self) -> str: + return "tea" + + @property + def priority(self) -> int: + return 45 + + def supports(self, purl: PackageURL) -> bool: + """Supported when PURL type has a known TEA domain or TEA_BASE_URL is set.""" + return purl.type in PURL_TYPE_TO_TEA_DOMAIN or bool(os.getenv("TEA_BASE_URL")) + + def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetadata | None: + """Discover TEA server from PURL type and fetch metadata.""" + purl_str = _purl_to_search_value(purl) + cache_key = f"tea:{purl_str}" + + if cache_key in _cache: + logger.debug(f"Cache hit (tea): {purl_str}") + return _cache[cache_key] + + try: + metadata = self._fetch_from_tea(purl, purl_str) + _cache[cache_key] = metadata + return metadata + except Exception as exc: + logger.warning(f"TEA enrichment failed for {purl_str}: {exc}") + _cache[cache_key] = None + return None + + def _fetch_from_tea(self, purl: PackageURL, purl_str: str) -> NormalizedMetadata | None: + """Build TEI, discover server, and fetch metadata.""" + token = os.getenv("TEA_TOKEN") + base_url_override = os.getenv("TEA_BASE_URL") + + try: + if base_url_override: + # Direct override — skip discovery + client = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) + else: + # Auto-discover from PURL type + domain = PURL_TYPE_TO_TEA_DOMAIN.get(purl.type) + if not domain: + return None + tei = _build_tei(purl, domain) + logger.debug(f"TEI discovery: {tei}") + client = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) + + # Search for product releases matching this PURL + response = client.search_product_releases(id_type="PURL", id_value=purl_str, page_size=1) + if not response.results: + logger.debug(f"No TEA product releases found for: {purl_str}") + return None + + release = response.results[0] + logger.debug(f"Found TEA product release: {release.product_name} {release.version} ({release.uuid})") + + field_sources: dict[str, str] = {} + + # Extract release date + cle_release_date: str | None = None + if release.release_date: + cle_release_date = release.release_date.isoformat() + field_sources["cle_release_date"] = self.name + + # Extract product name as supplier + supplier: str | None = release.product_name + if supplier: + field_sources["supplier"] = self.name + + # Fetch CLE lifecycle data + cle_eos: str | None = None + cle_eol: str | None = None + license_expr: str | None = None + try: + cle = client.get_product_release_cle(release.uuid) + for event in cle.events: + if event.type == CLEEventType.END_OF_SUPPORT and not cle_eos: + cle_eos = event.effective.isoformat() + field_sources["cle_eos"] = self.name + elif event.type == CLEEventType.END_OF_LIFE and not cle_eol: + cle_eol = event.effective.isoformat() + field_sources["cle_eol"] = self.name + elif event.type == CLEEventType.RELEASED and event.license and not license_expr: + license_expr = event.license + field_sources["licenses"] = self.name + except TeaNotFoundError: + logger.debug(f"No CLE data for release {release.uuid}") + except TeaError as exc: + logger.debug(f"CLE lookup failed for {release.uuid}: {exc}") + + licenses = [license_expr] if license_expr else [] + + metadata = NormalizedMetadata( + supplier=supplier, + licenses=licenses, + cle_release_date=cle_release_date, + cle_eos=cle_eos, + cle_eol=cle_eol, + source=self.name, + field_sources=field_sources, + ) + + if metadata.has_data(): + logger.debug(f"Successfully enriched from TEA: {purl_str}") + return metadata + return None + + except TeaNotFoundError: + logger.debug(f"PURL not found on TEA server: {purl_str}") + return None + except TeaError as exc: + logger.warning(f"TEA server error for {purl_str}: {exc}") + return None diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py new file mode 100644 index 00000000..c5be4dbe --- /dev/null +++ b/tests/test_tea_enrichment.py @@ -0,0 +1,347 @@ +"""Tests for the TEA enrichment source.""" + +import unittest +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +from packageurl import PackageURL + +from sbomify_action._enrichment.sources.tea import ( + PURL_TYPE_TO_TEA_DOMAIN, + TeaSource, + _build_tei, + _purl_to_search_value, + clear_cache, +) + + +class TestTeaSourceProperties(unittest.TestCase): + """Test TeaSource name and priority.""" + + def test_name(self): + assert TeaSource().name == "tea" + + def test_priority(self): + assert TeaSource().priority == 45 + + +class TestTeaSourceSupports(unittest.TestCase): + """Test supports based on PURL type mapping and env var override.""" + + def test_supports_mapped_purl_type(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + with patch.dict("os.environ", {}, clear=True): + assert source.supports(purl) is True + + def test_does_not_support_unmapped_type_without_env(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + with patch.dict("os.environ", {}, clear=True): + assert source.supports(purl) is False + + def test_supports_any_type_with_base_url_override(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + with patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}): + assert source.supports(purl) is True + + def test_supports_all_mapped_types(self): + source = TeaSource() + with patch.dict("os.environ", {}, clear=True): + for purl_type in PURL_TYPE_TO_TEA_DOMAIN: + purl = PackageURL(type=purl_type, name="test", version="1.0") + assert source.supports(purl) is True, f"Should support {purl_type}" + + +class TestBuildTei(unittest.TestCase): + """Test TEI URN construction from PURL.""" + + def test_pypi_tei(self): + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + tei = _build_tei(purl, "pypi.sbomify.com") + assert tei == "urn:tei:purl:pypi.sbomify.com:pkg:pypi/requests@2.31.0" + + def test_namespaced_purl_tei(self): + purl = PackageURL.from_string("pkg:deb/ubuntu/openssl@3.0.2") + tei = _build_tei(purl, "ubuntu.com") + assert tei == "urn:tei:purl:ubuntu.com:pkg:deb/ubuntu/openssl@3.0.2" + + def test_purl_without_version(self): + purl = PackageURL.from_string("pkg:pypi/requests") + tei = _build_tei(purl, "pypi.sbomify.com") + assert tei == "urn:tei:purl:pypi.sbomify.com:pkg:pypi/requests" + + +class TestPurlToSearchValue(unittest.TestCase): + """Test PURL string formatting for TEA search.""" + + def test_simple_purl(self): + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + assert _purl_to_search_value(purl) == "pkg:pypi/requests@2.31.0" + + def test_namespaced_purl(self): + purl = PackageURL.from_string("pkg:maven/org.apache/commons-lang@3.12") + assert _purl_to_search_value(purl) == "pkg:maven/org.apache/commons-lang@3.12" + + def test_purl_without_version(self): + purl = PackageURL.from_string("pkg:pypi/requests") + assert _purl_to_search_value(purl) == "pkg:pypi/requests" + + def test_purl_strips_qualifiers(self): + purl = PackageURL.from_string("pkg:deb/debian/bash@5.1?arch=amd64") + assert _purl_to_search_value(purl) == "pkg:deb/debian/bash@5.1" + + +class TestTeaSourceFetch(unittest.TestCase): + """Test fetch behavior with mocked TeaClient.""" + + def setUp(self): + clear_cache() + self.source = TeaSource() + self.session = MagicMock() + self.purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + + def tearDown(self): + clear_cache() + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_auto_discovery(self, mock_client_cls): + """Fetch uses from_well_known with domain from PURL type mapping.""" + from libtea.models import ( + CLE, + CLEEvent, + CLEEventType, + Identifier, + PaginatedProductReleaseResponse, + ProductRelease, + ) + + now = datetime.now(timezone.utc) + release = ProductRelease( + uuid="pr-uuid", + product="prod-uuid", + product_name="requests", + version="2.31.0", + created_date=now, + release_date=now, + identifiers=(Identifier(id_type="PURL", id_value="pkg:pypi/requests@2.31.0"),), + components=(), + ) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=1, + results=(release,), + ) + mock_client.get_product_release_cle.return_value = CLE( + events=( + CLEEvent( + id=1, + type=CLEEventType.RELEASED, + effective=now, + published=now, + version="2.31.0", + license="Apache-2.0", + ), + CLEEvent( + id=2, + type=CLEEventType.END_OF_SUPPORT, + effective=datetime(2025, 12, 31, tzinfo=timezone.utc), + published=now, + ), + CLEEvent( + id=3, + type=CLEEventType.END_OF_LIFE, + effective=datetime(2026, 6, 30, tzinfo=timezone.utc), + published=now, + ), + ), + definitions=None, + ) + + metadata = self.source.fetch(self.purl, self.session) + + # Verify discovery used the mapped domain + mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token=None, timeout=15) + + assert metadata is not None + assert metadata.source == "tea" + assert metadata.supplier == "requests" + assert metadata.licenses == ["Apache-2.0"] + assert metadata.cle_release_date == now.isoformat() + assert metadata.cle_eos == "2025-12-31T00:00:00+00:00" + assert metadata.cle_eol == "2026-06-30T00:00:00+00:00" + assert metadata.field_sources["licenses"] == "tea" + assert metadata.field_sources["cle_eos"] == "tea" + assert metadata.field_sources["cle_eol"] == "tea" + + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_base_url_override(self, mock_client_cls): + """TEA_BASE_URL overrides auto-discovery.""" + from libtea.models import PaginatedProductReleaseResponse + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=0, + results=(), + ) + + # Use an unmapped type — should still work with TEA_BASE_URL + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + self.source.fetch(purl, self.session) + + # Should use direct URL, not from_well_known + mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token=None, timeout=15) + mock_client_cls.from_well_known.assert_not_called() + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_no_cle(self, mock_client_cls): + """Fetch returns basic metadata when CLE is not available.""" + from libtea.exceptions import TeaNotFoundError + from libtea.models import ( + PaginatedProductReleaseResponse, + ProductRelease, + ) + + now = datetime.now(timezone.utc) + release = ProductRelease( + uuid="pr-uuid", + product="prod-uuid", + product_name="requests", + version="2.31.0", + created_date=now, + release_date=now, + identifiers=(), + components=(), + ) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=1, + results=(release,), + ) + mock_client.get_product_release_cle.side_effect = TeaNotFoundError("No CLE") + + metadata = self.source.fetch(self.purl, self.session) + + assert metadata is not None + assert metadata.supplier == "requests" + assert metadata.cle_release_date is not None + assert metadata.cle_eos is None + assert metadata.cle_eol is None + assert metadata.licenses == [] + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_not_found(self, mock_client_cls): + """Fetch returns None when PURL is not on TEA server.""" + from libtea.models import PaginatedProductReleaseResponse + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=0, + results=(), + ) + + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_connection_error(self, mock_client_cls): + """Fetch returns None on connection error.""" + from libtea.exceptions import TeaConnectionError + + mock_client_cls.from_well_known.side_effect = TeaConnectionError("Connection refused") + + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_caches_result(self, mock_client_cls): + """Second fetch for same PURL should use cache.""" + from libtea.models import PaginatedProductReleaseResponse + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=0, + results=(), + ) + + self.source.fetch(self.purl, self.session) + self.source.fetch(self.purl, self.session) + + # from_well_known should only be called once (second call uses cache) + assert mock_client_cls.from_well_known.call_count == 1 + + @patch.dict("os.environ", {}, clear=True) + def test_fetch_unmapped_type_returns_none(self): + """Fetch returns None for unmapped PURL type without TEA_BASE_URL.""" + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + metadata = self.source.fetch(purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {"TEA_TOKEN": "secret-token"}) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_passes_token(self, mock_client_cls): + """TEA_TOKEN env var is passed to client.""" + from libtea.models import PaginatedProductReleaseResponse + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=0, + results=(), + ) + + self.source.fetch(self.purl, self.session) + mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token="secret-token", timeout=15) + + +class TestTeaInRegistry(unittest.TestCase): + """Test that TeaSource is registered in the default registry.""" + + def test_tea_in_default_registry(self): + from sbomify_action._enrichment.enricher import create_default_registry + + registry = create_default_registry() + source_names = [s.name for s in registry._sources] + assert "tea" in source_names + + def test_clear_all_caches_includes_tea(self): + """clear_all_caches should not raise (tea cache is included).""" + from sbomify_action._enrichment.enricher import clear_all_caches + + clear_all_caches() From 564296b7c507708bfc433fedf14d166563b90c3f Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Fri, 6 Mar 2026 20:24:27 +0300 Subject: [PATCH 08/40] fix: address review findings for TEA integration - Fix stale docstring: TeaSource auto-discovers, not opt-in - Remove unused _build_tei (TEI was constructed but never used) - Cache TeaClient per domain/base_url to reuse connections - Fix media type matching: use == instead of substring `in` - Add test for TEA_BASE_URL + TEA_TOKEN together - Add test verifying client reuse across different PURLs --- sbomify_action/_enrichment/enricher.py | 2 +- sbomify_action/_enrichment/sources/tea.py | 51 +++++++++-------- sbomify_action/cli/tea.py | 2 +- tests/test_tea_enrichment.py | 70 ++++++++++++++++------- 4 files changed, 78 insertions(+), 47 deletions(-) diff --git a/sbomify_action/_enrichment/enricher.py b/sbomify_action/_enrichment/enricher.py index a4ef01ca..67415d75 100644 --- a/sbomify_action/_enrichment/enricher.py +++ b/sbomify_action/_enrichment/enricher.py @@ -58,7 +58,7 @@ def create_default_registry() -> SourceRegistry: Tier 2 - Primary Aggregators (40-49): - DepsDevSource (40) - Google Open Source Insights - - TeaSource (45) - TEA server (opt-in, requires TEA_BASE_URL or TEA_DOMAIN) + - TeaSource (45) - TEA server (auto-discovers from PURL type, TEA_BASE_URL overrides) - EcosystemsSource (45) - ecosyste.ms multi-ecosystem aggregator Tier 3 - Fallback Sources (70-99): diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index e810c750..d8c0a42b 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -4,8 +4,8 @@ data using TEI auto-discovery from PURL type. Each PURL type maps to a known TEA domain (e.g. ``pypi`` → ``pypi.sbomify.com``). -The source builds a TEI URN, discovers the TEA server via ``.well-known/tea``, -and fetches CLE lifecycle data. +The source discovers the TEA server via ``.well-known/tea`` and fetches CLE +lifecycle data. ``TEA_BASE_URL`` env var overrides auto-discovery for all PURL types. @@ -36,22 +36,36 @@ } _cache: dict[str, NormalizedMetadata | None] = {} +_client_cache: dict[str, TeaClient] = {} DEFAULT_TIMEOUT = 15 def clear_cache() -> None: - """Clear the module-level cache.""" + """Clear the module-level caches.""" _cache.clear() + _client_cache.clear() -def _build_tei(purl: PackageURL, domain: str) -> str: - """Build a TEI URN from a PURL and domain. +def _get_client(purl_type: str) -> TeaClient | None: + """Get or create a cached TeaClient for the given PURL type.""" + token = os.getenv("TEA_TOKEN") + base_url_override = os.getenv("TEA_BASE_URL") - Format: ``urn:tei:purl:{domain}:{purl_string}`` - """ - purl_str = _purl_to_search_value(purl) - return f"urn:tei:purl:{domain}:{purl_str}" + if base_url_override: + cache_key = f"base_url:{base_url_override}" + if cache_key not in _client_cache: + _client_cache[cache_key] = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) + return _client_cache[cache_key] + + domain = PURL_TYPE_TO_TEA_DOMAIN.get(purl_type) + if not domain: + return None + + cache_key = f"domain:{domain}" + if cache_key not in _client_cache: + _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) + return _client_cache[cache_key] def _purl_to_search_value(purl: PackageURL) -> str: @@ -99,22 +113,11 @@ def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetada return None def _fetch_from_tea(self, purl: PackageURL, purl_str: str) -> NormalizedMetadata | None: - """Build TEI, discover server, and fetch metadata.""" - token = os.getenv("TEA_TOKEN") - base_url_override = os.getenv("TEA_BASE_URL") - + """Discover server and fetch metadata.""" try: - if base_url_override: - # Direct override — skip discovery - client = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) - else: - # Auto-discover from PURL type - domain = PURL_TYPE_TO_TEA_DOMAIN.get(purl.type) - if not domain: - return None - tei = _build_tei(purl, domain) - logger.debug(f"TEI discovery: {tei}") - client = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) + client = _get_client(purl.type) + if not client: + return None # Search for product releases matching this PURL response = client.search_product_releases(id_type="PURL", id_value=purl_str, page_size=1) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 9cb1dfb5..9fdc29c8 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -30,7 +30,7 @@ def _select_best_format(formats, preferred_media_types=_BOM_MEDIA_TYPES): """Select the best artifact format by media type preference.""" for preferred in preferred_media_types: for fmt in formats: - if fmt.media_type and preferred in fmt.media_type: + if fmt.media_type and fmt.media_type == preferred: return fmt for fmt in formats: if fmt.url: diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index c5be4dbe..e8bea591 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -9,7 +9,6 @@ from sbomify_action._enrichment.sources.tea import ( PURL_TYPE_TO_TEA_DOMAIN, TeaSource, - _build_tei, _purl_to_search_value, clear_cache, ) @@ -54,25 +53,6 @@ def test_supports_all_mapped_types(self): assert source.supports(purl) is True, f"Should support {purl_type}" -class TestBuildTei(unittest.TestCase): - """Test TEI URN construction from PURL.""" - - def test_pypi_tei(self): - purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") - tei = _build_tei(purl, "pypi.sbomify.com") - assert tei == "urn:tei:purl:pypi.sbomify.com:pkg:pypi/requests@2.31.0" - - def test_namespaced_purl_tei(self): - purl = PackageURL.from_string("pkg:deb/ubuntu/openssl@3.0.2") - tei = _build_tei(purl, "ubuntu.com") - assert tei == "urn:tei:purl:ubuntu.com:pkg:deb/ubuntu/openssl@3.0.2" - - def test_purl_without_version(self): - purl = PackageURL.from_string("pkg:pypi/requests") - tei = _build_tei(purl, "pypi.sbomify.com") - assert tei == "urn:tei:purl:pypi.sbomify.com:pkg:pypi/requests" - - class TestPurlToSearchValue(unittest.TestCase): """Test PURL string formatting for TEA search.""" @@ -206,6 +186,26 @@ def test_fetch_base_url_override(self, mock_client_cls): mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token=None, timeout=15) mock_client_cls.from_well_known.assert_not_called() + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1", "TEA_TOKEN": "secret"}) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_base_url_with_token(self, mock_client_cls): + """TEA_BASE_URL + TEA_TOKEN are passed together.""" + from libtea.models import PaginatedProductReleaseResponse + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=0, + results=(), + ) + + self.source.fetch(self.purl, self.session) + mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token="secret", timeout=15) + @patch.dict("os.environ", {}, clear=True) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_no_cle(self, mock_client_cls): @@ -299,8 +299,36 @@ def test_fetch_caches_result(self, mock_client_cls): self.source.fetch(self.purl, self.session) self.source.fetch(self.purl, self.session) - # from_well_known should only be called once (second call uses cache) + # from_well_known should only be called once (client is cached) + assert mock_client_cls.from_well_known.call_count == 1 + # search should also only be called once (metadata result is cached) + assert mock_client.search_product_releases.call_count == 1 + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_client_reused_across_purls(self, mock_client_cls): + """Same domain reuses the cached TeaClient for different PURLs.""" + from libtea.models import PaginatedProductReleaseResponse + + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + timestamp=now, + page_start_index=0, + page_size=1, + total_results=0, + results=(), + ) + + purl1 = PackageURL.from_string("pkg:pypi/requests@2.31.0") + purl2 = PackageURL.from_string("pkg:pypi/flask@3.0.0") + self.source.fetch(purl1, self.session) + self.source.fetch(purl2, self.session) + + # Client created once, but search called twice (different PURLs) assert mock_client_cls.from_well_known.call_count == 1 + assert mock_client.search_product_releases.call_count == 2 @patch.dict("os.environ", {}, clear=True) def test_fetch_unmapped_type_returns_none(self): From e63547265f1ec6f5b34967614efb1323ed6743d7 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Fri, 6 Mar 2026 20:36:48 +0300 Subject: [PATCH 09/40] chore: switch libtea from TestPyPI to PyPI libtea 0.4.0 is now published on real PyPI. Remove TestPyPI index config and update dependency pin from >=0.4.0.dev17 to >=0.4.0. --- pyproject.toml | 10 +--------- uv.lock | 10 +++++----- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f80584b7..28ad1f4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "conan>=2.0,<3", "questionary>=2.0.1,<3", "pipdeptree>=2.0.0", - "libtea>=0.4.0.dev17", + "libtea>=0.4.0", ] [project.urls] @@ -107,14 +107,6 @@ exclude = [ "docs/", ] -[[tool.uv.index]] -name = "testpypi" -url = "https://test.pypi.org/simple/" -explicit = true - -[tool.uv.sources] -libtea = { index = "testpypi" } - [tool.ruff] line-length = 120 target-version = "py310" diff --git a/uv.lock b/uv.lock index ca6cb39c..1da94a13 100644 --- a/uv.lock +++ b/uv.lock @@ -752,16 +752,16 @@ wheels = [ [[package]] name = "libtea" -version = "0.4.0.dev17" -source = { registry = "https://test.pypi.org/simple/" } +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "requests" }, { name = "semver" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/27/ee/3614c1fa0f32ca6a5614c061e30cad6239417ac35146470924f7f947a18e/libtea-0.4.0.dev17.tar.gz", hash = "sha256:217df94cb112799b596db99522894c4be474477ea35fc390d4180717b1cb4955", size = 60450, upload-time = "2026-03-05T08:58:27.923Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/53/47a81863a148ed2218183af00620a90ccc4aec3d9ab46ef832deb584921e/libtea-0.4.0.tar.gz", hash = "sha256:f68d5976b8372dc8bdda416f9414fced7d09f2957fad95da85f257fe88b10656", size = 60452, upload-time = "2026-03-05T09:03:31.74Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/10/57/6fe614787a85799aa276d5358f38ee79406a069e1bb75c7f7904860ab813/libtea-0.4.0.dev17-py3-none-any.whl", hash = "sha256:3b5dcada5b3a145a4769a857cfabc4cf56b21c914bcecd6cc568149d27bd44e5", size = 67788, upload-time = "2026-03-05T08:58:26.358Z" }, + { url = "https://files.pythonhosted.org/packages/84/62/ac649a211a027a4b64471b0c857dcb1536941a86c3fe0eeedc9ca83df4d1/libtea-0.4.0-py3-none-any.whl", hash = "sha256:1c40ab69644730a1b81dc89a59eeec8b04d4421d97d8c0e0becc323bffdcd521", size = 67712, upload-time = "2026-03-05T09:03:30.538Z" }, ] [[package]] @@ -1786,7 +1786,7 @@ requires-dist = [ { name = "conan", specifier = ">=2.0,<3" }, { name = "cyclonedx-bom", specifier = ">=7.2.1,<8" }, { name = "cyclonedx-python-lib", specifier = ">=11.5.0,<12" }, - { name = "libtea", specifier = ">=0.4.0.dev17", index = "https://test.pypi.org/simple/" }, + { name = "libtea", specifier = ">=0.4.0" }, { name = "packageurl-python", specifier = ">=0.17.6" }, { name = "pipdeptree", specifier = ">=2.0.0" }, { name = "questionary", specifier = ">=2.0.1,<3" }, From 41253fb0a4147d9f07c9927859621604094bafbc Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Fri, 6 Mar 2026 21:23:58 +0300 Subject: [PATCH 10/40] fix: include CLE data in early exit condition for enrichment registry Without this, the registry stops querying sources once NTIA fields (description, licenses, supplier) are filled, which means TEA's CLE lifecycle data (release date, EOS, EOL) would never be fetched since higher-priority sources like PyPI already fill those fields. --- sbomify_action/_enrichment/registry.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 59502c1b..5f6f0fde 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -90,8 +90,14 @@ def fetch_metadata( result: Optional[NormalizedMetadata] = None for source in sources: - # Stop early if we already have all core NTIA fields - if result and result.description and result.licenses and result.supplier: + # Stop early if we already have all core NTIA fields + CLE lifecycle data + if ( + result + and result.description + and result.licenses + and result.supplier + and result.cle_release_date + ): logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") break From 8ec8e9cfbb49ccbd5c72c9e926fe18f6fd00bda7 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Fri, 6 Mar 2026 21:30:33 +0300 Subject: [PATCH 11/40] style: fix ruff formatting in registry.py --- sbomify_action/_enrichment/registry.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 5f6f0fde..11c9880f 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -91,13 +91,7 @@ def fetch_metadata( for source in sources: # Stop early if we already have all core NTIA fields + CLE lifecycle data - if ( - result - and result.description - and result.licenses - and result.supplier - and result.cle_release_date - ): + if result and result.description and result.licenses and result.supplier and result.cle_release_date: logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") break From 17b9ddbbc214381fa6e16b9d622da18c53e38fde Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 16:16:21 +0300 Subject: [PATCH 12/40] fix: address comprehensive review findings for TEA integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: Smart early exit in registry — after NTIA fields are filled, skip non-CLE sources but continue querying lifecycle-capable sources (tea, sbomify-lifecycle-db). Avoids the performance regression of querying all sources while still collecting CLE data when available. P1: Pin libtea>=0.4.0,<1 to prevent breaking changes from private API coupling. Add SSRF validation for TEA_BASE_URL (reject private IPs, localhost, link-local, cloud metadata hostnames). Add --allow-private-ips runtime warning. P2: Fix priority collision (43 vs 45 for ecosyste.ms). Add mutual exclusivity validation for fetch identifiers. Update --token help text with security guidance. Document unused session parameter. P3: Fix _purl_to_search_value to use PackageURL.to_string() for proper URL encoding. Add type annotations to _select_best_format. Add cle_release_date to has_data(). Close cached clients in clear_cache(). Refactor test duplication with shared helpers. Add 18 new test cases (SSRF validation, duplicate CLE events, error paths, cache cleanup, mutual exclusivity, priority ordering). --- pyproject.toml | 2 +- sbomify_action/_enrichment/enricher.py | 2 +- sbomify_action/_enrichment/metadata.py | 1 + sbomify_action/_enrichment/registry.py | 18 +- sbomify_action/_enrichment/sources/tea.py | 54 ++++- sbomify_action/cli/tea.py | 25 +- tests/test_tea_cli.py | 264 +++++++++++---------- tests/test_tea_enrichment.py | 272 ++++++++++++++-------- uv.lock | 2 +- 9 files changed, 398 insertions(+), 242 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28ad1f4b..7a4bbd99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "conan>=2.0,<3", "questionary>=2.0.1,<3", "pipdeptree>=2.0.0", - "libtea>=0.4.0", + "libtea>=0.4.0,<1", ] [project.urls] diff --git a/sbomify_action/_enrichment/enricher.py b/sbomify_action/_enrichment/enricher.py index 67415d75..096161b4 100644 --- a/sbomify_action/_enrichment/enricher.py +++ b/sbomify_action/_enrichment/enricher.py @@ -58,7 +58,7 @@ def create_default_registry() -> SourceRegistry: Tier 2 - Primary Aggregators (40-49): - DepsDevSource (40) - Google Open Source Insights - - TeaSource (45) - TEA server (auto-discovers from PURL type, TEA_BASE_URL overrides) + - TeaSource (43) - TEA server (auto-discovers from PURL type, TEA_BASE_URL overrides) - EcosystemsSource (45) - ecosyste.ms multi-ecosystem aggregator Tier 3 - Fallback Sources (70-99): diff --git a/sbomify_action/_enrichment/metadata.py b/sbomify_action/_enrichment/metadata.py index 2891d3bd..fac35a82 100644 --- a/sbomify_action/_enrichment/metadata.py +++ b/sbomify_action/_enrichment/metadata.py @@ -129,4 +129,5 @@ def has_data(self) -> bool: or self.maintainer_name or self.cle_eos or self.cle_eol + or self.cle_release_date ) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 11c9880f..1b54cd74 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -10,6 +10,11 @@ from .metadata import NormalizedMetadata from .protocol import DataSource +# Sources known to provide CLE (Common Lifecycle Enumeration) data. +# Used by the early exit logic to avoid skipping lifecycle-capable sources +# when NTIA fields are already satisfied but CLE data is still missing. +_CLE_PROVIDERS = frozenset({"tea", "sbomify-lifecycle-db"}) + class SourceRegistry: """ @@ -90,10 +95,15 @@ def fetch_metadata( result: Optional[NormalizedMetadata] = None for source in sources: - # Stop early if we already have all core NTIA fields + CLE lifecycle data - if result and result.description and result.licenses and result.supplier and result.cle_release_date: - logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") - break + # Stop early if we already have all core NTIA fields + if result and result.description and result.licenses and result.supplier: + if result.cle_release_date: + logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") + break + # NTIA complete but CLE missing — only continue with lifecycle-capable sources + if source.name not in _CLE_PROVIDERS: + logger.debug(f"Skipping {source.name} - NTIA complete, not a CLE provider for {purl.name}") + continue try: metadata = source.fetch(purl, session) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index d8c0a42b..ee132e5e 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -17,7 +17,9 @@ Priority 45 (Tier 2 aggregator). """ +import ipaddress import os +from urllib.parse import urlparse import requests from libtea import TeaClient @@ -41,8 +43,37 @@ DEFAULT_TIMEOUT = 15 +_BLOCKED_HOSTNAMES = frozenset({"localhost", "metadata.google.internal", "kubernetes.default.svc"}) + + +def _is_safe_url(url: str) -> bool: + """Check that a URL does not point to private/internal addresses.""" + try: + parsed = urlparse(url) + hostname = parsed.hostname + if not hostname: + return False + if hostname.lower() in _BLOCKED_HOSTNAMES: + return False + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + return False + except ValueError: + pass # Not an IP literal — hostname is fine + return True + except Exception: + return False + + def clear_cache() -> None: - """Clear the module-level caches.""" + """Clear the module-level caches (closes cached clients).""" + for client in _client_cache.values(): + if hasattr(client, "close"): + try: + client.close() + except Exception: + pass _cache.clear() _client_cache.clear() @@ -53,6 +84,9 @@ def _get_client(purl_type: str) -> TeaClient | None: base_url_override = os.getenv("TEA_BASE_URL") if base_url_override: + if not _is_safe_url(base_url_override): + logger.warning(f"TEA_BASE_URL rejected (private/internal address): {base_url_override}") + return None cache_key = f"base_url:{base_url_override}" if cache_key not in _client_cache: _client_cache[cache_key] = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) @@ -70,17 +104,17 @@ def _get_client(purl_type: str) -> TeaClient | None: def _purl_to_search_value(purl: PackageURL) -> str: """Convert a PackageURL to a canonical string (no qualifiers/subpath).""" - parts = [f"pkg:{purl.type}/"] - if purl.namespace: - parts.append(f"{purl.namespace}/") - parts.append(purl.name) - if purl.version: - parts.append(f"@{purl.version}") - return "".join(parts) + return PackageURL(type=purl.type, namespace=purl.namespace, name=purl.name, version=purl.version).to_string() class TeaSource: - """Enrichment source that discovers TEA servers and fetches CLE data.""" + """Enrichment source that discovers TEA servers and fetches CLE data. + + Note: The ``session`` parameter in ``fetch()`` is accepted for protocol + compliance but unused. libtea manages its own HTTP transport with separate + SSRF protections, authentication, and retry logic. TEA requests will not + carry the shared session's User-Agent or proxy configuration. + """ @property def name(self) -> str: @@ -88,7 +122,7 @@ def name(self) -> str: @property def priority(self) -> int: - return 45 + return 43 def supports(self, purl: PackageURL) -> bool: """Supported when PURL type has a known TEA domain or TEA_BASE_URL is set.""" diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 9fdc29c8..a3fe17d5 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -5,6 +5,7 @@ """ import sys +from collections.abc import Sequence from pathlib import Path import click @@ -15,7 +16,7 @@ from libtea.cli import _build_client, _error from libtea.cli import app as tea_group from libtea.exceptions import TeaError -from libtea.models import ArtifactType +from libtea.models import ArtifactFormat, ArtifactType __all__ = ["tea_group"] @@ -26,7 +27,10 @@ ) -def _select_best_format(formats, preferred_media_types=_BOM_MEDIA_TYPES): +def _select_best_format( + formats: Sequence[ArtifactFormat], + preferred_media_types: tuple[str, ...] = _BOM_MEDIA_TYPES, +) -> ArtifactFormat | None: """Select the best artifact format by media type preference.""" for preferred in preferred_media_types: for fmt in formats: @@ -51,12 +55,17 @@ def _select_best_format(formats, preferred_media_types=_BOM_MEDIA_TYPES): @click.option("-o", "--output", "output_path", required=True, type=click.Path(), help="Output file path") @click.option("--base-url", envvar="TEA_BASE_URL", default=None, help="TEA server base URL") @click.option("--domain", default=None, help="Domain for .well-known/tea discovery") -@click.option("--token", envvar="TEA_TOKEN", default=None, help="Bearer token") +@click.option( + "--token", + envvar="TEA_TOKEN", + default=None, + help="Bearer token (prefer TEA_TOKEN env var to avoid shell history exposure)", +) @click.option("--auth", envvar="TEA_AUTH", default=None, help="Basic auth as USER:PASSWORD") @click.option("--timeout", type=click.FloatRange(min=0.1), default=30.0, help="Request timeout") @click.option("--use-http", is_flag=True, help="Use HTTP instead of HTTPS") @click.option("--port", type=int, default=None, help="Port for well-known resolution") -@click.option("--allow-private-ips", is_flag=True, help="Allow private IPs") +@click.option("--allow-private-ips", is_flag=True, help="Allow private IPs (WARNING: weakens SSRF protections)") def fetch( tei, product_release_uuid, @@ -83,8 +92,14 @@ def fetch( sbomify-action tea fetch --tei "urn:tei:purl:example.com:pkg:pypi/requests@2.31" -o sbom.json sbomify-action tea fetch --product-release-uuid abc-123 -o sbom.json --base-url https://tea.example.com/v1 """ - if not tei and not product_release_uuid and not component_release_uuid: + identifiers = sum(1 for x in (tei, product_release_uuid, component_release_uuid) if x) + if identifiers == 0: _error("Must specify --tei, --product-release-uuid, or --component-release-uuid") + if identifiers > 1: + _error("Only one of --tei, --product-release-uuid, or --component-release-uuid may be specified") + + if allow_private_ips: + print("WARNING: --allow-private-ips weakens SSRF protections for artifact downloads", file=sys.stderr) target_type = ArtifactType(artifact_type) dest = Path(output_path) diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index cfda9b73..3b6763b4 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -5,11 +5,62 @@ from unittest.mock import MagicMock, patch from click.testing import CliRunner +from libtea.exceptions import TeaError +from libtea.models import ( + Artifact, + ArtifactFormat, + ArtifactType, + Checksum, + ChecksumAlgorithm, + Collection, + CollectionBelongsTo, + DiscoveryInfo, + TeaServerInfo, +) from sbomify_action.cli.main import cli from sbomify_action.cli.tea import _select_best_format +def _mock_client_context(mock_build_client): + """Set up mock_build_client as a context manager returning a MagicMock client.""" + mock_client = MagicMock() + mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) + mock_build_client.return_value.__exit__ = MagicMock(return_value=False) + return mock_client + + +def _make_collection(belongs_to, artifacts): + """Create a Collection with defaults filled in.""" + return Collection( + uuid="col-uuid", + version=1, + date=None, + belongs_to=belongs_to, + update_reason=None, + artifacts=artifacts, + ) + + +def _make_bom_artifact(media_type="application/vnd.cyclonedx+json", url="https://cdn.example.com/sbom.json", **kwargs): + """Create a BOM Artifact with a single format.""" + return Artifact( + uuid="art-uuid", + name="sbom", + type=ArtifactType.BOM, + distribution_types=None, + formats=[ArtifactFormat(media_type=media_type, description=None, url=url, signature_url=None, **kwargs)], + ) + + +def _make_discovery(pr_uuid="pr-uuid-1"): + """Create a DiscoveryInfo.""" + return DiscoveryInfo( + product_release_uuid=pr_uuid, + servers=[TeaServerInfo(root_url="https://tea.example.com/v1", versions=["0.3.0-beta.2"], priority=1.0)], + ) + + class TestTeaGroup(unittest.TestCase): """Test that the tea subcommand group is registered.""" @@ -63,58 +114,41 @@ def test_fetch_help(self): assert "fetch" in result.output.lower() or "SBOM" in result.output def test_fetch_requires_identifier(self): - """tea fetch should fail if no --tei or --product-release-uuid given.""" + """tea fetch should fail with clear error if no identifier given.""" result = self.runner.invoke( cli, ["tea", "fetch", "--base-url", "https://tea.example.com/v1", "-o", "sbom.json"], ) assert result.exit_code != 0 + assert "--tei" in result.output or "Must specify" in result.output + + def test_fetch_rejects_multiple_identifiers(self): + """tea fetch should reject multiple identifiers.""" + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:test", + "--product-release-uuid", + "some-uuid", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + assert "Only one" in result.output or result.exit_code == 1 @patch("sbomify_action.cli.tea._build_client") def test_fetch_by_tei(self, mock_build_client): """tea fetch --tei should discover, find BOM, and download.""" - from libtea.models import ( - Artifact, - ArtifactFormat, - ArtifactType, - Collection, - CollectionBelongsTo, - DiscoveryInfo, - TeaServerInfo, - ) - - mock_client = MagicMock() - mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) - mock_build_client.return_value.__exit__ = MagicMock(return_value=False) - - mock_client.discover.return_value = [ - DiscoveryInfo( - product_release_uuid="pr-uuid-1", - servers=[TeaServerInfo(root_url="https://tea.example.com/v1", versions=["0.3.0-beta.2"], priority=1.0)], - ) - ] - mock_client.get_product_release_collection_latest.return_value = Collection( - uuid="col-uuid", - version=1, - date=None, - belongs_to=CollectionBelongsTo.PRODUCT_RELEASE, - update_reason=None, - artifacts=[ - Artifact( - uuid="art-uuid", - name="sbom", - type=ArtifactType.BOM, - distribution_types=None, - formats=[ - ArtifactFormat( - media_type="application/vnd.cyclonedx+json", - description=None, - url="https://cdn.example.com/sbom.json", - signature_url=None, - ) - ], - ) - ], + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.return_value = [_make_discovery()] + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [_make_bom_artifact()] ) mock_client.download_artifact.return_value = "/tmp/sbom.json" @@ -139,49 +173,26 @@ def test_fetch_by_tei(self, mock_build_client): @patch("sbomify_action.cli.tea._build_client") def test_fetch_no_bom_artifact(self, mock_build_client): """tea fetch should error when no BOM artifact found.""" - from libtea.models import ( - Artifact, - ArtifactFormat, - ArtifactType, - Collection, - CollectionBelongsTo, - DiscoveryInfo, - TeaServerInfo, - ) - - mock_client = MagicMock() - mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) - mock_build_client.return_value.__exit__ = MagicMock(return_value=False) - - mock_client.discover.return_value = [ - DiscoveryInfo( - product_release_uuid="pr-uuid-1", - servers=[TeaServerInfo(root_url="https://tea.example.com/v1", versions=["0.3.0-beta.2"], priority=1.0)], - ) - ] - mock_client.get_product_release_collection_latest.return_value = Collection( - uuid="col-uuid", - version=1, - date=None, - belongs_to=CollectionBelongsTo.PRODUCT_RELEASE, - update_reason=None, - artifacts=[ - Artifact( - uuid="art-uuid", - name="vex", - type=ArtifactType.VULNERABILITIES, - distribution_types=None, - formats=[ - ArtifactFormat( - media_type="application/json", - description=None, - url="https://cdn.example.com/vex.json", - signature_url=None, - ) - ], + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.return_value = [_make_discovery()] + + vex_artifact = Artifact( + uuid="art-uuid", + name="vex", + type=ArtifactType.VULNERABILITIES, + distribution_types=None, + formats=[ + ArtifactFormat( + media_type="application/json", + description=None, + url="https://cdn.example.com/vex.json", + signature_url=None, ) ], ) + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [vex_artifact] + ) with self.runner.isolated_filesystem(): result = self.runner.invoke( @@ -202,44 +213,11 @@ def test_fetch_no_bom_artifact(self, mock_build_client): @patch("sbomify_action.cli.tea._build_client") def test_fetch_by_component_release_uuid(self, mock_build_client): """tea fetch --component-release-uuid should fetch without discovery.""" - from libtea.models import ( - Artifact, - ArtifactFormat, - ArtifactType, - Checksum, - ChecksumAlgorithm, - Collection, - CollectionBelongsTo, - ) - - mock_client = MagicMock() - mock_build_client.return_value.__enter__ = MagicMock(return_value=mock_client) - mock_build_client.return_value.__exit__ = MagicMock(return_value=False) + mock_client = _mock_client_context(mock_build_client) checksums = (Checksum(algorithm_type=ChecksumAlgorithm.SHA_256, algorithm_value="abc123"),) - mock_client.get_component_release_collection_latest.return_value = Collection( - uuid="col-uuid", - version=1, - date=None, - belongs_to=CollectionBelongsTo.COMPONENT_RELEASE, - update_reason=None, - artifacts=[ - Artifact( - uuid="art-uuid", - name="sbom", - type=ArtifactType.BOM, - distribution_types=None, - formats=[ - ArtifactFormat( - media_type="application/vnd.cyclonedx+json", - description=None, - url="https://cdn.example.com/sbom.json", - signature_url=None, - checksums=checksums, - ) - ], - ) - ], + mock_client.get_component_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.COMPONENT_RELEASE, [_make_bom_artifact(checksums=checksums)] ) mock_client.download_artifact.return_value = Path("/tmp/sbom.json") @@ -265,6 +243,54 @@ def test_fetch_by_component_release_uuid(self, mock_build_client): call_kwargs = mock_client.download_artifact.call_args assert call_kwargs.kwargs.get("verify_checksums") == checksums + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_tea_error(self, mock_build_client): + """tea fetch should handle TeaError gracefully.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.side_effect = TeaError("Server error") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:test", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_io_error(self, mock_build_client): + """tea fetch should handle OSError gracefully.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.discover.return_value = [_make_discovery()] + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [_make_bom_artifact()] + ) + mock_client.download_artifact.side_effect = OSError("Disk full") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--tei", + "urn:tei:test", + "-o", + "sbom.json", + ], + ) + assert result.exit_code != 0 + class TestSelectBestFormat(unittest.TestCase): """Test the _select_best_format helper.""" diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index e8bea591..41b98c79 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -4,11 +4,21 @@ from datetime import datetime, timezone from unittest.mock import MagicMock, patch +from libtea.exceptions import TeaConnectionError, TeaNotFoundError +from libtea.models import ( + CLE, + CLEEvent, + CLEEventType, + Identifier, + PaginatedProductReleaseResponse, + ProductRelease, +) from packageurl import PackageURL from sbomify_action._enrichment.sources.tea import ( PURL_TYPE_TO_TEA_DOMAIN, TeaSource, + _is_safe_url, _purl_to_search_value, clear_cache, ) @@ -21,7 +31,7 @@ def test_name(self): assert TeaSource().name == "tea" def test_priority(self): - assert TeaSource().priority == 45 + assert TeaSource().priority == 43 class TestTeaSourceSupports(unittest.TestCase): @@ -72,6 +82,41 @@ def test_purl_strips_qualifiers(self): purl = PackageURL.from_string("pkg:deb/debian/bash@5.1?arch=amd64") assert _purl_to_search_value(purl) == "pkg:deb/debian/bash@5.1" + def test_purl_strips_subpath(self): + purl = PackageURL.from_string("pkg:maven/org.apache/commons@1.0#sub/path") + assert _purl_to_search_value(purl) == "pkg:maven/org.apache/commons@1.0" + + +class TestIsSafeUrl(unittest.TestCase): + """Test SSRF validation for TEA_BASE_URL.""" + + def test_public_url_allowed(self): + assert _is_safe_url("https://tea.example.com/v1") is True + + def test_private_ip_rejected(self): + assert _is_safe_url("http://192.168.1.1/v1") is False + assert _is_safe_url("http://10.0.0.1/v1") is False + assert _is_safe_url("http://172.16.0.1/v1") is False + + def test_loopback_rejected(self): + assert _is_safe_url("http://127.0.0.1/v1") is False + + def test_link_local_rejected(self): + assert _is_safe_url("http://169.254.169.254/latest/meta-data/") is False + + def test_localhost_rejected(self): + assert _is_safe_url("http://localhost/v1") is False + + def test_metadata_hostnames_rejected(self): + assert _is_safe_url("http://metadata.google.internal/v1") is False + assert _is_safe_url("http://kubernetes.default.svc/v1") is False + + def test_empty_url_rejected(self): + assert _is_safe_url("") is False + + def test_no_hostname_rejected(self): + assert _is_safe_url("file:///etc/passwd") is False + class TestTeaSourceFetch(unittest.TestCase): """Test fetch behavior with mocked TeaClient.""" @@ -85,40 +130,39 @@ def setUp(self): def tearDown(self): clear_cache() - @patch.dict("os.environ", {}, clear=True) - @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) - def test_fetch_auto_discovery(self, mock_client_cls): - """Fetch uses from_well_known with domain from PURL type mapping.""" - from libtea.models import ( - CLE, - CLEEvent, - CLEEventType, - Identifier, - PaginatedProductReleaseResponse, - ProductRelease, - ) - - now = datetime.now(timezone.utc) - release = ProductRelease( + def _make_release(self, now, has_release_date=True): + """Create a ProductRelease for testing.""" + return ProductRelease( uuid="pr-uuid", product="prod-uuid", product_name="requests", version="2.31.0", created_date=now, - release_date=now, + release_date=now if has_release_date else None, identifiers=(Identifier(id_type="PURL", id_value="pkg:pypi/requests@2.31.0"),), components=(), ) - mock_client = MagicMock() - mock_client_cls.from_well_known.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( + def _make_search_response(self, now, releases=()): + """Create a PaginatedProductReleaseResponse for testing.""" + return PaginatedProductReleaseResponse( timestamp=now, page_start_index=0, page_size=1, - total_results=1, - results=(release,), + total_results=len(releases), + results=releases, ) + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_auto_discovery(self, mock_client_cls): + """Fetch uses from_well_known with domain from PURL type mapping.""" + now = datetime.now(timezone.utc) + release = self._make_release(now) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now, (release,)) mock_client.get_product_release_cle.return_value = CLE( events=( CLEEvent( @@ -161,22 +205,17 @@ def test_fetch_auto_discovery(self, mock_client_cls): assert metadata.field_sources["cle_eos"] == "tea" assert metadata.field_sources["cle_eol"] == "tea" + # Session is intentionally unused (libtea manages its own HTTP transport) + self.session.assert_not_called() + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_base_url_override(self, mock_client_cls): """TEA_BASE_URL overrides auto-discovery.""" - from libtea.models import PaginatedProductReleaseResponse - now = datetime.now(timezone.utc) mock_client = MagicMock() mock_client_cls.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=0, - results=(), - ) + mock_client.search_product_releases.return_value = self._make_search_response(now) # Use an unmapped type — should still work with TEA_BASE_URL purl = PackageURL.from_string("pkg:cargo/serde@1.0") @@ -190,53 +229,36 @@ def test_fetch_base_url_override(self, mock_client_cls): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_base_url_with_token(self, mock_client_cls): """TEA_BASE_URL + TEA_TOKEN are passed together.""" - from libtea.models import PaginatedProductReleaseResponse - now = datetime.now(timezone.utc) mock_client = MagicMock() mock_client_cls.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=0, - results=(), - ) + mock_client.search_product_releases.return_value = self._make_search_response(now) self.source.fetch(self.purl, self.session) mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token="secret", timeout=15) + @patch.dict("os.environ", {"TEA_BASE_URL": "http://169.254.169.254/latest"}) + def test_fetch_rejects_private_base_url(self): + """TEA_BASE_URL pointing to private IP is rejected.""" + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + + @patch.dict("os.environ", {"TEA_BASE_URL": "http://localhost:8080/v1"}) + def test_fetch_rejects_localhost_base_url(self): + """TEA_BASE_URL pointing to localhost is rejected.""" + metadata = self.source.fetch(self.purl, self.session) + assert metadata is None + @patch.dict("os.environ", {}, clear=True) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_no_cle(self, mock_client_cls): """Fetch returns basic metadata when CLE is not available.""" - from libtea.exceptions import TeaNotFoundError - from libtea.models import ( - PaginatedProductReleaseResponse, - ProductRelease, - ) - now = datetime.now(timezone.utc) - release = ProductRelease( - uuid="pr-uuid", - product="prod-uuid", - product_name="requests", - version="2.31.0", - created_date=now, - release_date=now, - identifiers=(), - components=(), - ) + release = self._make_release(now) mock_client = MagicMock() mock_client_cls.from_well_known.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=1, - results=(release,), - ) + mock_client.search_product_releases.return_value = self._make_search_response(now, (release,)) mock_client.get_product_release_cle.side_effect = TeaNotFoundError("No CLE") metadata = self.source.fetch(self.purl, self.session) @@ -252,18 +274,10 @@ def test_fetch_no_cle(self, mock_client_cls): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_not_found(self, mock_client_cls): """Fetch returns None when PURL is not on TEA server.""" - from libtea.models import PaginatedProductReleaseResponse - now = datetime.now(timezone.utc) mock_client = MagicMock() mock_client_cls.from_well_known.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=0, - results=(), - ) + mock_client.search_product_releases.return_value = self._make_search_response(now) metadata = self.source.fetch(self.purl, self.session) assert metadata is None @@ -272,8 +286,6 @@ def test_fetch_not_found(self, mock_client_cls): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_connection_error(self, mock_client_cls): """Fetch returns None on connection error.""" - from libtea.exceptions import TeaConnectionError - mock_client_cls.from_well_known.side_effect = TeaConnectionError("Connection refused") metadata = self.source.fetch(self.purl, self.session) @@ -283,18 +295,10 @@ def test_fetch_connection_error(self, mock_client_cls): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_caches_result(self, mock_client_cls): """Second fetch for same PURL should use cache.""" - from libtea.models import PaginatedProductReleaseResponse - now = datetime.now(timezone.utc) mock_client = MagicMock() mock_client_cls.from_well_known.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=0, - results=(), - ) + mock_client.search_product_releases.return_value = self._make_search_response(now) self.source.fetch(self.purl, self.session) self.source.fetch(self.purl, self.session) @@ -308,18 +312,10 @@ def test_fetch_caches_result(self, mock_client_cls): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_client_reused_across_purls(self, mock_client_cls): """Same domain reuses the cached TeaClient for different PURLs.""" - from libtea.models import PaginatedProductReleaseResponse - now = datetime.now(timezone.utc) mock_client = MagicMock() mock_client_cls.from_well_known.return_value = mock_client - mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=0, - results=(), - ) + mock_client.search_product_releases.return_value = self._make_search_response(now) purl1 = PackageURL.from_string("pkg:pypi/requests@2.31.0") purl2 = PackageURL.from_string("pkg:pypi/flask@3.0.0") @@ -341,21 +337,85 @@ def test_fetch_unmapped_type_returns_none(self): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_passes_token(self, mock_client_cls): """TEA_TOKEN env var is passed to client.""" - from libtea.models import PaginatedProductReleaseResponse + now = datetime.now(timezone.utc) + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now) + + self.source.fetch(self.purl, self.session) + mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token="secret-token", timeout=15) + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_fetch_duplicate_cle_events_takes_first(self, mock_client_cls): + """When multiple CLE events of the same type exist, the first one wins.""" + now = datetime.now(timezone.utc) + release = self._make_release(now) + + mock_client = MagicMock() + mock_client_cls.from_well_known.return_value = mock_client + mock_client.search_product_releases.return_value = self._make_search_response(now, (release,)) + mock_client.get_product_release_cle.return_value = CLE( + events=( + CLEEvent( + id=1, + type=CLEEventType.END_OF_SUPPORT, + effective=datetime(2025, 6, 1, tzinfo=timezone.utc), + published=now, + ), + CLEEvent( + id=2, + type=CLEEventType.END_OF_SUPPORT, + effective=datetime(2025, 12, 31, tzinfo=timezone.utc), + published=now, + ), + ), + definitions=None, + ) + + metadata = self.source.fetch(self.purl, self.session) + assert metadata is not None + assert metadata.cle_eos == "2025-06-01T00:00:00+00:00" + + +class TestClearCache(unittest.TestCase): + """Test that clear_cache properly cleans up.""" + + @patch.dict("os.environ", {}, clear=True) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_clear_cache_clears_results_and_clients(self, mock_client_cls): + """clear_cache should clear both result cache and client cache.""" + from sbomify_action._enrichment.sources.tea import _cache, _client_cache now = datetime.now(timezone.utc) mock_client = MagicMock() mock_client_cls.from_well_known.return_value = mock_client mock_client.search_product_releases.return_value = PaginatedProductReleaseResponse( - timestamp=now, - page_start_index=0, - page_size=1, - total_results=0, - results=(), + timestamp=now, page_start_index=0, page_size=1, total_results=0, results=() ) - self.source.fetch(self.purl, self.session) - mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token="secret-token", timeout=15) + source = TeaSource() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + source.fetch(purl, MagicMock()) + + assert len(_cache) > 0 + assert len(_client_cache) > 0 + + clear_cache() + + assert len(_cache) == 0 + assert len(_client_cache) == 0 + + def test_clear_cache_closes_clients(self): + """clear_cache should call close() on cached clients.""" + from sbomify_action._enrichment.sources.tea import _client_cache + + mock_client = MagicMock() + _client_cache["test"] = mock_client + + clear_cache() + + mock_client.close.assert_called_once() class TestTeaInRegistry(unittest.TestCase): @@ -365,9 +425,19 @@ def test_tea_in_default_registry(self): from sbomify_action._enrichment.enricher import create_default_registry registry = create_default_registry() - source_names = [s.name for s in registry._sources] + source_names = [s["name"] for s in registry.list_sources()] assert "tea" in source_names + def test_tea_priority_before_ecosystems(self): + """TEA should run before ecosyste.ms (no priority collision).""" + from sbomify_action._enrichment.enricher import create_default_registry + + registry = create_default_registry() + sources = registry.list_sources() + tea = next(s for s in sources if s["name"] == "tea") + eco = next(s for s in sources if s["name"] == "ecosyste.ms") + assert tea["priority"] < eco["priority"] + def test_clear_all_caches_includes_tea(self): """clear_all_caches should not raise (tea cache is included).""" from sbomify_action._enrichment.enricher import clear_all_caches diff --git a/uv.lock b/uv.lock index 1da94a13..545a5c49 100644 --- a/uv.lock +++ b/uv.lock @@ -1786,7 +1786,7 @@ requires-dist = [ { name = "conan", specifier = ">=2.0,<3" }, { name = "cyclonedx-bom", specifier = ">=7.2.1,<8" }, { name = "cyclonedx-python-lib", specifier = ">=11.5.0,<12" }, - { name = "libtea", specifier = ">=0.4.0" }, + { name = "libtea", specifier = ">=0.4.0,<1" }, { name = "packageurl-python", specifier = ">=0.17.6" }, { name = "pipdeptree", specifier = ">=2.0.0" }, { name = "questionary", specifier = ">=2.0.1,<3" }, From 28b698b98b1331133d5851f27ee5fa47cd83ea4b Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 17:00:54 +0300 Subject: [PATCH 13/40] fix: address PR review findings for TEA integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix _select_best_format to skip preferred formats without a URL, falling back to secondary formats that have downloadable URLs - Fix stale docstring priority (45 → 43) in tea.py module header - Add test_skips_preferred_format_without_url test case --- sbomify_action/_enrichment/sources/tea.py | 2 +- sbomify_action/cli/tea.py | 2 +- tests/test_tea_cli.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index ee132e5e..11f9694e 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -14,7 +14,7 @@ - License information from CLE ``released`` events - Product name as supplier context -Priority 45 (Tier 2 aggregator). +Priority 43 (Tier 2 aggregator). """ import ipaddress diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index a3fe17d5..523d6cfe 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -34,7 +34,7 @@ def _select_best_format( """Select the best artifact format by media type preference.""" for preferred in preferred_media_types: for fmt in formats: - if fmt.media_type and fmt.media_type == preferred: + if fmt.media_type and fmt.media_type == preferred and fmt.url: return fmt for fmt in formats: if fmt.url: diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index 3b6763b4..39c4b17a 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -320,6 +320,12 @@ def test_skips_format_without_url(self): def test_returns_none_for_empty(self): assert _select_best_format([]) is None + def test_skips_preferred_format_without_url(self): + """Preferred media type without URL should fall back to secondary format with URL.""" + cdx_no_url = self._make_fmt("application/vnd.cyclonedx+json", None) + spdx_with_url = self._make_fmt("application/spdx+json", "https://a.com/spdx.json") + assert _select_best_format([cdx_no_url, spdx_with_url]) is spdx_with_url + def test_returns_none_when_no_url(self): no_url = self._make_fmt("application/xml", None) assert _select_best_format([no_url]) is None From 77d11d3be3ba117c974e79f44dcc412705d50e1b Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 20:31:16 +0300 Subject: [PATCH 14/40] fix: address second round of PR review findings - Add is_unspecified check to _is_safe_url (blocks 0.0.0.0/::) - Include TEA_TOKEN in client cache key to prevent stale auth - Require all 3 CLE fields before early exit in registry - Add tests for unspecified address and token-aware caching --- sbomify_action/_enrichment/registry.py | 2 +- sbomify_action/_enrichment/sources/tea.py | 6 +++--- tests/test_tea_enrichment.py | 26 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 1b54cd74..71475d67 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -97,7 +97,7 @@ def fetch_metadata( for source in sources: # Stop early if we already have all core NTIA fields if result and result.description and result.licenses and result.supplier: - if result.cle_release_date: + if result.cle_release_date and result.cle_eos and result.cle_eol: logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") break # NTIA complete but CLE missing — only continue with lifecycle-capable sources diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 11f9694e..5a284c4f 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -57,7 +57,7 @@ def _is_safe_url(url: str) -> bool: return False try: ip = ipaddress.ip_address(hostname) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified: return False except ValueError: pass # Not an IP literal — hostname is fine @@ -87,7 +87,7 @@ def _get_client(purl_type: str) -> TeaClient | None: if not _is_safe_url(base_url_override): logger.warning(f"TEA_BASE_URL rejected (private/internal address): {base_url_override}") return None - cache_key = f"base_url:{base_url_override}" + cache_key = f"base_url:{base_url_override}:token:{token or ''}" if cache_key not in _client_cache: _client_cache[cache_key] = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) return _client_cache[cache_key] @@ -96,7 +96,7 @@ def _get_client(purl_type: str) -> TeaClient | None: if not domain: return None - cache_key = f"domain:{domain}" + cache_key = f"domain:{domain}:token:{token or ''}" if cache_key not in _client_cache: _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) return _client_cache[cache_key] diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index 41b98c79..6f073796 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -111,6 +111,9 @@ def test_metadata_hostnames_rejected(self): assert _is_safe_url("http://metadata.google.internal/v1") is False assert _is_safe_url("http://kubernetes.default.svc/v1") is False + def test_unspecified_address_rejected(self): + assert _is_safe_url("http://0.0.0.0/v1") is False + def test_empty_url_rejected(self): assert _is_safe_url("") is False @@ -345,6 +348,29 @@ def test_fetch_passes_token(self, mock_client_cls): self.source.fetch(self.purl, self.session) mock_client_cls.from_well_known.assert_called_once_with("pypi.sbomify.com", token="secret-token", timeout=15) + @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) + def test_token_change_creates_new_client(self, mock_client_cls): + """Changing TEA_TOKEN should create a new client, not reuse the cached one.""" + now = datetime.now(timezone.utc) + mock_client1 = MagicMock() + mock_client2 = MagicMock() + mock_client_cls.from_well_known.side_effect = [mock_client1, mock_client2] + empty_response = self._make_search_response(now) + mock_client1.search_product_releases.return_value = empty_response + mock_client2.search_product_releases.return_value = empty_response + + with patch.dict("os.environ", {"TEA_TOKEN": "token-1"}, clear=True): + self.source.fetch(self.purl, self.session) + clear_cache() + + with patch.dict("os.environ", {"TEA_TOKEN": "token-2"}, clear=True): + self.source.fetch(self.purl, self.session) + + assert mock_client_cls.from_well_known.call_count == 2 + calls = mock_client_cls.from_well_known.call_args_list + assert calls[0].kwargs["token"] == "token-1" + assert calls[1].kwargs["token"] == "token-2" + @patch.dict("os.environ", {}, clear=True) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_duplicate_cle_events_takes_first(self, mock_client_cls): From 6d3a69747a1a7764737fc3b622d7f81df5df6b6a Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 20:51:22 +0300 Subject: [PATCH 15/40] fix: add registry CLE skip tests and mock TEA in non-TEA tests - Add conftest fixture to mock TeaClient.from_well_known in non-TEA tests, preventing real network calls via libtea's own HTTP transport - Add test_early_exit_skips_non_cle_sources_after_ntia_complete to verify _CLE_PROVIDERS logic in registry - Add test_early_exit_breaks_when_all_cle_fields_present to verify full break when NTIA + all 3 CLE fields are satisfied --- tests/conftest.py | 20 +++++++++ tests/test_enrichment_module.py | 77 +++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index bbe53e40..44f7eac2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ """Pytest configuration and shared fixtures for all tests.""" +from unittest.mock import MagicMock, patch + import pytest @@ -13,3 +15,21 @@ def disable_sentry_for_tests(monkeypatch): this by setting TELEMETRY=true in their own fixtures or patches. """ monkeypatch.setenv("TELEMETRY", "false") + + +@pytest.fixture(autouse=True) +def mock_tea_client(request): + """Prevent TeaSource from making real network calls in non-TEA tests. + + libtea uses its own HTTP transport, so mocking requests.Session does not + intercept TEA network calls. This fixture patches TeaClient.from_well_known + globally so that only tests in test_tea_*.py (which patch it themselves) + make deliberate use of the client. + """ + if request.module.__name__.startswith("tests.test_tea_") or request.module.__name__.startswith("test_tea_"): + yield + return + mock_client = MagicMock() + mock_client.search_product_releases.return_value = MagicMock(results=()) + with patch("sbomify_action._enrichment.sources.tea.TeaClient.from_well_known", return_value=mock_client): + yield diff --git a/tests/test_enrichment_module.py b/tests/test_enrichment_module.py index 9d8f7723..00a83959 100644 --- a/tests/test_enrichment_module.py +++ b/tests/test_enrichment_module.py @@ -708,6 +708,83 @@ def test_sources_sorted_by_priority(self): priorities = [s.priority for s in sources] assert priorities == sorted(priorities) + def test_early_exit_skips_non_cle_sources_after_ntia_complete(self): + """After NTIA fields are filled, non-CLE sources should be skipped.""" + registry = SourceRegistry() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + session = Mock() + + # Source 1: fills all NTIA fields (priority 10) + ntia_source = Mock() + ntia_source.name = "pypi.org" + ntia_source.priority = 10 + ntia_source.supports.return_value = True + ntia_source.fetch.return_value = NormalizedMetadata( + description="HTTP library", licenses=["Apache-2.0"], supplier="Kenneth Reitz", source="pypi.org" + ) + + # Source 2: non-CLE source (priority 40) — should be skipped + non_cle_source = Mock() + non_cle_source.name = "deps.dev" + non_cle_source.priority = 40 + non_cle_source.supports.return_value = True + + # Source 3: CLE provider (priority 43) — should still run + cle_source = Mock() + cle_source.name = "tea" + cle_source.priority = 43 + cle_source.supports.return_value = True + cle_source.fetch.return_value = NormalizedMetadata( + cle_release_date="2024-01-01", cle_eos="2025-12-31", cle_eol="2026-06-30", source="tea" + ) + + registry.register(ntia_source) + registry.register(non_cle_source) + registry.register(cle_source) + + result = registry.fetch_metadata(purl, session) + + ntia_source.fetch.assert_called_once() + non_cle_source.fetch.assert_not_called() + cle_source.fetch.assert_called_once() + assert result.cle_eos == "2025-12-31" + + def test_early_exit_breaks_when_all_cle_fields_present(self): + """When NTIA + all 3 CLE fields are filled, remaining sources should be skipped entirely.""" + registry = SourceRegistry() + purl = PackageURL.from_string("pkg:pypi/requests@2.31.0") + session = Mock() + + # Source fills everything + full_source = Mock() + full_source.name = "pypi.org" + full_source.priority = 10 + full_source.supports.return_value = True + full_source.fetch.return_value = NormalizedMetadata( + description="HTTP library", + licenses=["Apache-2.0"], + supplier="Kenneth Reitz", + cle_release_date="2024-01-01", + cle_eos="2025-12-31", + cle_eol="2026-06-30", + source="pypi.org", + ) + + # Should never run + extra_source = Mock() + extra_source.name = "tea" + extra_source.priority = 43 + extra_source.supports.return_value = True + + registry.register(full_source) + registry.register(extra_source) + + result = registry.fetch_metadata(purl, session) + + full_source.fetch.assert_called_once() + extra_source.fetch.assert_not_called() + assert result.cle_eol == "2026-06-30" + # ============================================================================= # Test Enricher From f016d4567da15ee6d47e8a60a5e23a479e06b805 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 21:12:13 +0300 Subject: [PATCH 16/40] fix: harden TEA mock fixture and clarify SSRF comment - Clear TEA_BASE_URL in conftest fixture to prevent constructor path from bypassing from_well_known mock when env var is set in CI - Update _is_safe_url comment to note DNS-based SSRF limitation --- sbomify_action/_enrichment/sources/tea.py | 2 +- tests/conftest.py | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 5a284c4f..a06265c7 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -60,7 +60,7 @@ def _is_safe_url(url: str) -> bool: if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified: return False except ValueError: - pass # Not an IP literal — hostname is fine + pass # Not an IP literal; DNS-based SSRF (e.g. nip.io) is not checked here return True except Exception: return False diff --git a/tests/conftest.py b/tests/conftest.py index 44f7eac2..9aecfd8d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,17 +18,20 @@ def disable_sentry_for_tests(monkeypatch): @pytest.fixture(autouse=True) -def mock_tea_client(request): +def mock_tea_client(request, monkeypatch): """Prevent TeaSource from making real network calls in non-TEA tests. libtea uses its own HTTP transport, so mocking requests.Session does not - intercept TEA network calls. This fixture patches TeaClient.from_well_known - globally so that only tests in test_tea_*.py (which patch it themselves) - make deliberate use of the client. + intercept TEA network calls. This fixture clears TEA_BASE_URL (so the + direct constructor path is never taken) and patches from_well_known to + return a no-op client. + + Tests in test_tea_*.py are skipped since they provide their own mocks. """ if request.module.__name__.startswith("tests.test_tea_") or request.module.__name__.startswith("test_tea_"): yield return + monkeypatch.delenv("TEA_BASE_URL", raising=False) mock_client = MagicMock() mock_client.search_product_releases.return_value = MagicMock(results=()) with patch("sbomify_action._enrichment.sources.tea.TeaClient.from_well_known", return_value=mock_client): From cefbb134b0a437b951a724e84f181591149d842a Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 21:28:37 +0300 Subject: [PATCH 17/40] fix: make collection assignment type-safe in tea fetch Initialize collection to None before if/elif/else and add assertion after _error() branch so type checkers see it as always bound. --- sbomify_action/cli/tea.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 523d6cfe..10190651 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -118,6 +118,7 @@ def fetch( pr_uuid = discoveries[0].product_release_uuid print(f"Discovered product release: {pr_uuid}", file=sys.stderr) + collection = None if pr_uuid: collection = client.get_product_release_collection_latest(pr_uuid) elif cr_uuid: @@ -125,6 +126,7 @@ def fetch( else: _error("Internal error: no UUID resolved") + assert collection is not None # unreachable: _error() is NoReturn matching = [a for a in collection.artifacts if a.type == target_type] if not matching: available = {a.type.value for a in collection.artifacts if a.type} From 787ca82c7d81b5a185c173ee0a8062c2867e944f Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 22:27:57 +0300 Subject: [PATCH 18/40] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20strengthen=20token=20hash,=20modernize=20types,=20r?= =?UTF-8?q?emove=20supplier=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increase token hash prefix from 8 to 16 chars in TEA cache keys for better collision resistance - Remove product_name as supplier (it's the package name, not the supplier) - Remove redundant inner try/except in _fetch_from_tea (caller handles exceptions) - Modernize registry.py type annotations to use built-in generics (list, dict, X | None) --- sbomify_action/_enrichment/registry.py | 12 +-- sbomify_action/_enrichment/sources/tea.py | 126 ++++++++++------------ tests/test_tea_enrichment.py | 4 +- 3 files changed, 64 insertions(+), 78 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 71475d67..29447ef5 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -1,6 +1,6 @@ """Source registry for managing data source plugins.""" -from typing import Any, Dict, List, Optional +from typing import Any import requests from packageurl import PackageURL @@ -38,7 +38,7 @@ class SourceRegistry: def __init__(self) -> None: """Initialize an empty registry.""" - self._sources: List[DataSource] = [] + self._sources: list[DataSource] = [] def register(self, source: DataSource) -> None: """ @@ -52,7 +52,7 @@ def register(self, source: DataSource) -> None: self._sources.append(source) logger.debug(f"Registered data source: {source.name} (priority={source.priority})") - def get_sources_for(self, purl: PackageURL) -> List[DataSource]: + def get_sources_for(self, purl: PackageURL) -> list[DataSource]: """ Get all applicable sources for a PURL, sorted by priority. @@ -71,7 +71,7 @@ def fetch_metadata( purl: PackageURL, session: requests.Session, merge_results: bool = True, - ) -> Optional[NormalizedMetadata]: + ) -> NormalizedMetadata | None: """ Fetch metadata using the priority chain of sources. @@ -92,7 +92,7 @@ def fetch_metadata( logger.debug(f"No sources available for PURL type: {purl.type}") return None - result: Optional[NormalizedMetadata] = None + result: NormalizedMetadata | None = None for source in sources: # Stop early if we already have all core NTIA fields @@ -124,7 +124,7 @@ def fetch_metadata( return result - def list_sources(self) -> List[Dict[str, Any]]: + def list_sources(self) -> list[dict[str, Any]]: """ List all registered sources with their priorities. diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index a06265c7..880f654f 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -12,11 +12,11 @@ Provides: - Release date, end-of-support, end-of-life from CLE events - License information from CLE ``released`` events -- Product name as supplier context Priority 43 (Tier 2 aggregator). """ +import hashlib import ipaddress import os from urllib.parse import urlparse @@ -87,7 +87,7 @@ def _get_client(purl_type: str) -> TeaClient | None: if not _is_safe_url(base_url_override): logger.warning(f"TEA_BASE_URL rejected (private/internal address): {base_url_override}") return None - cache_key = f"base_url:{base_url_override}:token:{token or ''}" + cache_key = f"base_url:{base_url_override}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: _client_cache[cache_key] = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) return _client_cache[cache_key] @@ -96,7 +96,7 @@ def _get_client(purl_type: str) -> TeaClient | None: if not domain: return None - cache_key = f"domain:{domain}:token:{token or ''}" + cache_key = f"domain:{domain}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) return _client_cache[cache_key] @@ -148,74 +148,60 @@ def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetada def _fetch_from_tea(self, purl: PackageURL, purl_str: str) -> NormalizedMetadata | None: """Discover server and fetch metadata.""" - try: - client = _get_client(purl.type) - if not client: - return None - - # Search for product releases matching this PURL - response = client.search_product_releases(id_type="PURL", id_value=purl_str, page_size=1) - if not response.results: - logger.debug(f"No TEA product releases found for: {purl_str}") - return None - - release = response.results[0] - logger.debug(f"Found TEA product release: {release.product_name} {release.version} ({release.uuid})") - - field_sources: dict[str, str] = {} - - # Extract release date - cle_release_date: str | None = None - if release.release_date: - cle_release_date = release.release_date.isoformat() - field_sources["cle_release_date"] = self.name - - # Extract product name as supplier - supplier: str | None = release.product_name - if supplier: - field_sources["supplier"] = self.name - - # Fetch CLE lifecycle data - cle_eos: str | None = None - cle_eol: str | None = None - license_expr: str | None = None - try: - cle = client.get_product_release_cle(release.uuid) - for event in cle.events: - if event.type == CLEEventType.END_OF_SUPPORT and not cle_eos: - cle_eos = event.effective.isoformat() - field_sources["cle_eos"] = self.name - elif event.type == CLEEventType.END_OF_LIFE and not cle_eol: - cle_eol = event.effective.isoformat() - field_sources["cle_eol"] = self.name - elif event.type == CLEEventType.RELEASED and event.license and not license_expr: - license_expr = event.license - field_sources["licenses"] = self.name - except TeaNotFoundError: - logger.debug(f"No CLE data for release {release.uuid}") - except TeaError as exc: - logger.debug(f"CLE lookup failed for {release.uuid}: {exc}") - - licenses = [license_expr] if license_expr else [] - - metadata = NormalizedMetadata( - supplier=supplier, - licenses=licenses, - cle_release_date=cle_release_date, - cle_eos=cle_eos, - cle_eol=cle_eol, - source=self.name, - field_sources=field_sources, - ) - - if metadata.has_data(): - logger.debug(f"Successfully enriched from TEA: {purl_str}") - return metadata + client = _get_client(purl.type) + if not client: return None - except TeaNotFoundError: - logger.debug(f"PURL not found on TEA server: {purl_str}") + # Search for product releases matching this PURL + response = client.search_product_releases(id_type="PURL", id_value=purl_str, page_size=1) + if not response.results: + logger.debug(f"No TEA product releases found for: {purl_str}") return None + + release = response.results[0] + logger.debug(f"Found TEA product release: {release.product_name} {release.version} ({release.uuid})") + + field_sources: dict[str, str] = {} + + # Extract release date + cle_release_date: str | None = None + if release.release_date: + cle_release_date = release.release_date.isoformat() + field_sources["cle_release_date"] = self.name + + # Fetch CLE lifecycle data + cle_eos: str | None = None + cle_eol: str | None = None + license_expr: str | None = None + try: + cle = client.get_product_release_cle(release.uuid) + for event in cle.events: + if event.type == CLEEventType.END_OF_SUPPORT and not cle_eos: + cle_eos = event.effective.isoformat() + field_sources["cle_eos"] = self.name + elif event.type == CLEEventType.END_OF_LIFE and not cle_eol: + cle_eol = event.effective.isoformat() + field_sources["cle_eol"] = self.name + elif event.type == CLEEventType.RELEASED and event.license and not license_expr: + license_expr = event.license + field_sources["licenses"] = self.name + except TeaNotFoundError: + logger.debug(f"No CLE data for release {release.uuid}") except TeaError as exc: - logger.warning(f"TEA server error for {purl_str}: {exc}") - return None + logger.debug(f"CLE lookup failed for {release.uuid}: {exc}") + + licenses = [license_expr] if license_expr else [] + + metadata = NormalizedMetadata( + licenses=licenses, + cle_release_date=cle_release_date, + cle_eos=cle_eos, + cle_eol=cle_eol, + source=self.name, + field_sources=field_sources, + ) + + if metadata.has_data(): + logger.debug(f"Successfully enriched from TEA: {purl_str}") + return metadata + return None diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index 6f073796..afbd080a 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -199,7 +199,7 @@ def test_fetch_auto_discovery(self, mock_client_cls): assert metadata is not None assert metadata.source == "tea" - assert metadata.supplier == "requests" + assert metadata.supplier is None assert metadata.licenses == ["Apache-2.0"] assert metadata.cle_release_date == now.isoformat() assert metadata.cle_eos == "2025-12-31T00:00:00+00:00" @@ -267,7 +267,7 @@ def test_fetch_no_cle(self, mock_client_cls): metadata = self.source.fetch(self.purl, self.session) assert metadata is not None - assert metadata.supplier == "requests" + assert metadata.supplier is None assert metadata.cle_release_date is not None assert metadata.cle_eos is None assert metadata.cle_eol is None From cc57f4dd0b0083b57b795dd2b3f611e64c732cc2 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 22:31:28 +0300 Subject: [PATCH 19/40] fix: scope metadata cache key by env config and add product-release-uuid test - Include TEA_BASE_URL and TEA_TOKEN hash in metadata result cache key to prevent stale results when env vars change mid-process - Add test for --product-release-uuid direct fetch path (no TEI discovery) --- sbomify_action/_enrichment/sources/tea.py | 5 +++- tests/test_tea_cli.py | 28 +++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 880f654f..290d68d3 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -131,7 +131,10 @@ def supports(self, purl: PackageURL) -> bool: def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetadata | None: """Discover TEA server from PURL type and fetch metadata.""" purl_str = _purl_to_search_value(purl) - cache_key = f"tea:{purl_str}" + token = os.getenv("TEA_TOKEN") or "" + base_url = os.getenv("TEA_BASE_URL") or "" + env_hash = hashlib.sha256(f"{base_url}:{token}".encode()).hexdigest()[:16] + cache_key = f"tea:{purl_str}:{env_hash}" if cache_key in _cache: logger.debug(f"Cache hit (tea): {purl_str}") diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index 39c4b17a..e4d6d39c 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -243,6 +243,34 @@ def test_fetch_by_component_release_uuid(self, mock_build_client): call_kwargs = mock_client.download_artifact.call_args assert call_kwargs.kwargs.get("verify_checksums") == checksums + @patch("sbomify_action.cli.tea._build_client") + def test_fetch_by_product_release_uuid(self, mock_build_client): + """tea fetch --product-release-uuid should fetch without discovery.""" + mock_client = _mock_client_context(mock_build_client) + mock_client.get_product_release_collection_latest.return_value = _make_collection( + CollectionBelongsTo.PRODUCT_RELEASE, [_make_bom_artifact()] + ) + mock_client.download_artifact.return_value = Path("/tmp/sbom.json") + + with self.runner.isolated_filesystem(): + result = self.runner.invoke( + cli, + [ + "tea", + "fetch", + "--base-url", + "https://tea.example.com/v1", + "--product-release-uuid", + "pr-uuid-1", + "-o", + "sbom.json", + ], + ) + assert result.exit_code == 0, f"Failed with: {result.output}" + mock_client.discover.assert_not_called() + mock_client.get_product_release_collection_latest.assert_called_once_with("pr-uuid-1") + mock_client.download_artifact.assert_called_once() + @patch("sbomify_action.cli.tea._build_client") def test_fetch_tea_error(self, mock_build_client): """tea fetch should handle TeaError gracefully.""" From e9e0ccd4c6d65594b56c6aee89378baa4af68395 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 22:51:58 +0300 Subject: [PATCH 20/40] fix: replace hardcoded _CLE_PROVIDERS with provides_cle protocol property - Add provides_cle property to DataSource protocol (defaults to False) - Set provides_cle=True on TeaSource and LifecycleSource - Registry now checks source.provides_cle instead of name-based frozenset - Fix trivially-true assertion in test_fetch_rejects_multiple_identifiers --- sbomify_action/_enrichment/protocol.py | 11 +++++++++++ sbomify_action/_enrichment/registry.py | 7 +------ sbomify_action/_enrichment/sources/lifecycle.py | 4 ++++ sbomify_action/_enrichment/sources/tea.py | 4 ++++ tests/test_enrichment_module.py | 2 ++ tests/test_tea_cli.py | 2 +- 6 files changed, 23 insertions(+), 7 deletions(-) diff --git a/sbomify_action/_enrichment/protocol.py b/sbomify_action/_enrichment/protocol.py index cca44b98..94f1e4c1 100644 --- a/sbomify_action/_enrichment/protocol.py +++ b/sbomify_action/_enrichment/protocol.py @@ -57,6 +57,17 @@ def priority(self) -> int: """ ... + @property + def provides_cle(self) -> bool: + """ + Whether this source can provide CLE (Common Lifecycle Enumeration) data. + + Defaults to False. Sources that supply cle_release_date, cle_eos, + or cle_eol should return True so the registry's early-exit logic + keeps querying them even after NTIA fields are satisfied. + """ + ... + def supports(self, purl: PackageURL) -> bool: """ Check if this source can handle the given PURL type. diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 29447ef5..120d3321 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -10,11 +10,6 @@ from .metadata import NormalizedMetadata from .protocol import DataSource -# Sources known to provide CLE (Common Lifecycle Enumeration) data. -# Used by the early exit logic to avoid skipping lifecycle-capable sources -# when NTIA fields are already satisfied but CLE data is still missing. -_CLE_PROVIDERS = frozenset({"tea", "sbomify-lifecycle-db"}) - class SourceRegistry: """ @@ -101,7 +96,7 @@ def fetch_metadata( logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") break # NTIA complete but CLE missing — only continue with lifecycle-capable sources - if source.name not in _CLE_PROVIDERS: + if not getattr(source, "provides_cle", False): logger.debug(f"Skipping {source.name} - NTIA complete, not a CLE provider for {purl.name}") continue diff --git a/sbomify_action/_enrichment/sources/lifecycle.py b/sbomify_action/_enrichment/sources/lifecycle.py index 174cda3d..66f9ec5b 100644 --- a/sbomify_action/_enrichment/sources/lifecycle.py +++ b/sbomify_action/_enrichment/sources/lifecycle.py @@ -63,6 +63,10 @@ class LifecycleSource: def name(self) -> str: return "sbomify-lifecycle-db" + @property + def provides_cle(self) -> bool: + return True + @property def priority(self) -> int: # Priority 5: Very high - local data with no API calls diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 290d68d3..54551ed8 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -124,6 +124,10 @@ def name(self) -> str: def priority(self) -> int: return 43 + @property + def provides_cle(self) -> bool: + return True + def supports(self, purl: PackageURL) -> bool: """Supported when PURL type has a known TEA domain or TEA_BASE_URL is set.""" return purl.type in PURL_TYPE_TO_TEA_DOMAIN or bool(os.getenv("TEA_BASE_URL")) diff --git a/tests/test_enrichment_module.py b/tests/test_enrichment_module.py index 00a83959..11ac0434 100644 --- a/tests/test_enrichment_module.py +++ b/tests/test_enrichment_module.py @@ -727,12 +727,14 @@ def test_early_exit_skips_non_cle_sources_after_ntia_complete(self): non_cle_source = Mock() non_cle_source.name = "deps.dev" non_cle_source.priority = 40 + non_cle_source.provides_cle = False non_cle_source.supports.return_value = True # Source 3: CLE provider (priority 43) — should still run cle_source = Mock() cle_source.name = "tea" cle_source.priority = 43 + cle_source.provides_cle = True cle_source.supports.return_value = True cle_source.fetch.return_value = NormalizedMetadata( cle_release_date="2024-01-01", cle_eos="2025-12-31", cle_eol="2026-06-30", source="tea" diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index e4d6d39c..7bc16bce 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -140,7 +140,7 @@ def test_fetch_rejects_multiple_identifiers(self): ], ) assert result.exit_code != 0 - assert "Only one" in result.output or result.exit_code == 1 + assert "Only one" in result.output @patch("sbomify_action.cli.tea._build_client") def test_fetch_by_tei(self, mock_build_client): From 4def1d9434aece8f1c85abb1a9e0a0e15fe4b836 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 23:12:16 +0300 Subject: [PATCH 21/40] fix: clarify provides_cle docstring, fix token cache test, add --auth security hint - Clarify that provides_cle default is applied by registry via getattr, not by the Protocol itself - Remove clear_cache() from test_token_change_creates_new_client so it tests token-based cache key differentiation directly - Add shell history exposure warning to --auth option help text --- sbomify_action/_enrichment/protocol.py | 9 ++++++--- sbomify_action/cli/tea.py | 2 +- tests/test_tea_enrichment.py | 3 +-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/sbomify_action/_enrichment/protocol.py b/sbomify_action/_enrichment/protocol.py index 94f1e4c1..39811c9d 100644 --- a/sbomify_action/_enrichment/protocol.py +++ b/sbomify_action/_enrichment/protocol.py @@ -62,9 +62,12 @@ def provides_cle(self) -> bool: """ Whether this source can provide CLE (Common Lifecycle Enumeration) data. - Defaults to False. Sources that supply cle_release_date, cle_eos, - or cle_eol should return True so the registry's early-exit logic - keeps querying them even after NTIA fields are satisfied. + Sources that supply cle_release_date, cle_eos, or cle_eol should + return True so the registry's early-exit logic keeps querying them + even after NTIA fields are satisfied. + + Sources that do not implement this property are treated as False + by the registry via ``getattr(source, "provides_cle", False)``. """ ... diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 10190651..3333b015 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -61,7 +61,7 @@ def _select_best_format( default=None, help="Bearer token (prefer TEA_TOKEN env var to avoid shell history exposure)", ) -@click.option("--auth", envvar="TEA_AUTH", default=None, help="Basic auth as USER:PASSWORD") +@click.option("--auth", envvar="TEA_AUTH", default=None, help="Basic auth as USER:PASSWORD (prefer TEA_AUTH env var to avoid shell history exposure)") @click.option("--timeout", type=click.FloatRange(min=0.1), default=30.0, help="Request timeout") @click.option("--use-http", is_flag=True, help="Use HTTP instead of HTTPS") @click.option("--port", type=int, default=None, help="Port for well-known resolution") diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index afbd080a..21eae57c 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -350,7 +350,7 @@ def test_fetch_passes_token(self, mock_client_cls): @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_token_change_creates_new_client(self, mock_client_cls): - """Changing TEA_TOKEN should create a new client, not reuse the cached one.""" + """Different TEA_TOKEN values produce different cache keys and separate clients.""" now = datetime.now(timezone.utc) mock_client1 = MagicMock() mock_client2 = MagicMock() @@ -361,7 +361,6 @@ def test_token_change_creates_new_client(self, mock_client_cls): with patch.dict("os.environ", {"TEA_TOKEN": "token-1"}, clear=True): self.source.fetch(self.purl, self.session) - clear_cache() with patch.dict("os.environ", {"TEA_TOKEN": "token-2"}, clear=True): self.source.fetch(self.purl, self.session) From 89f803e8b544b8e4c59690a461c7ff82d9b51924 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 23:18:04 +0300 Subject: [PATCH 22/40] style: fix ruff formatting in tea CLI --- sbomify_action/cli/tea.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 3333b015..ab4a6e11 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -61,7 +61,12 @@ def _select_best_format( default=None, help="Bearer token (prefer TEA_TOKEN env var to avoid shell history exposure)", ) -@click.option("--auth", envvar="TEA_AUTH", default=None, help="Basic auth as USER:PASSWORD (prefer TEA_AUTH env var to avoid shell history exposure)") +@click.option( + "--auth", + envvar="TEA_AUTH", + default=None, + help="Basic auth as USER:PASSWORD (prefer TEA_AUTH env var to avoid shell history exposure)", +) @click.option("--timeout", type=click.FloatRange(min=0.1), default=30.0, help="Request timeout") @click.option("--use-http", is_flag=True, help="Use HTTP instead of HTTPS") @click.option("--port", type=int, default=None, help="Port for well-known resolution") From ed084b2cc5d0dd4b688fe5d7fa12e66bb1a7fcea Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 23:30:40 +0300 Subject: [PATCH 23/40] fix: clear TEA client cache in conftest fixture for test isolation --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9aecfd8d..67421b39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,9 @@ def mock_tea_client(request, monkeypatch): if request.module.__name__.startswith("tests.test_tea_") or request.module.__name__.startswith("test_tea_"): yield return + from sbomify_action._enrichment.sources.tea import clear_cache as clear_tea_cache + + clear_tea_cache() monkeypatch.delenv("TEA_BASE_URL", raising=False) mock_client = MagicMock() mock_client.search_product_releases.return_value = MagicMock(results=()) From 31d14fb3fa642cf2712b925fb76a44d713daf1bf Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 9 Mar 2026 23:48:52 +0300 Subject: [PATCH 24/40] fix: remove dead fmt.url guard after _select_best_format --- sbomify_action/cli/tea.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index ab4a6e11..10c4e1b4 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -144,7 +144,7 @@ def fetch( _error(f"Artifact '{artifact.name}' has no downloadable formats") fmt = _select_best_format(artifact.formats) - if not fmt or not fmt.url: + if not fmt: _error(f"No downloadable format found for artifact '{artifact.name}'") print(f"Downloading {artifact.name} ({fmt.media_type}) ...", file=sys.stderr) From 4b83c032de4034c7dd699119f92243104d543d78 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 10 Mar 2026 00:00:44 +0300 Subject: [PATCH 25/40] fix: add clear=True to test_fetch_passes_token env patch --- tests/test_tea_enrichment.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index 21eae57c..cadafb71 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -336,7 +336,7 @@ def test_fetch_unmapped_type_returns_none(self): metadata = self.source.fetch(purl, self.session) assert metadata is None - @patch.dict("os.environ", {"TEA_TOKEN": "secret-token"}) + @patch.dict("os.environ", {"TEA_TOKEN": "secret-token"}, clear=True) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_passes_token(self, mock_client_cls): """TEA_TOKEN env var is passed to client.""" From 673cfc9c1459bbf3a3c1ffd998d3f05a20d018fa Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 10 Mar 2026 00:15:30 +0300 Subject: [PATCH 26/40] fix: cache from_well_known failures to avoid repeated connection attempts When the TEA server is unreachable, from_well_known() would be retried for every PURL in the SBOM. Now failures are cached as None in _client_cache so only one attempt is made per domain per process. --- sbomify_action/_enrichment/sources/tea.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 54551ed8..732f07a0 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -38,7 +38,7 @@ } _cache: dict[str, NormalizedMetadata | None] = {} -_client_cache: dict[str, TeaClient] = {} +_client_cache: dict[str, TeaClient | None] = {} DEFAULT_TIMEOUT = 15 @@ -98,7 +98,11 @@ def _get_client(purl_type: str) -> TeaClient | None: cache_key = f"domain:{domain}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: - _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) + try: + _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) + except Exception as exc: + logger.warning(f"TEA well-known discovery failed for {domain}: {exc}") + _client_cache[cache_key] = None return _client_cache[cache_key] From 127918d9e917564cf91f558f6984c1d0009b9645 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 10 Mar 2026 00:25:47 +0300 Subject: [PATCH 27/40] fix: retry-limited discovery failures and reject unsafe URLs in supports() - Replace permanent None caching of from_well_known failures with a retry counter (max 2 attempts) so transient errors can recover - Add _is_safe_url check to supports() so unsafe TEA_BASE_URL values are rejected early without entering the source chain --- sbomify_action/_enrichment/sources/tea.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 732f07a0..a8dc146f 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -39,6 +39,9 @@ _cache: dict[str, NormalizedMetadata | None] = {} _client_cache: dict[str, TeaClient | None] = {} +_discovery_failures: dict[str, int] = {} + +_MAX_DISCOVERY_RETRIES = 2 DEFAULT_TIMEOUT = 15 @@ -76,6 +79,7 @@ def clear_cache() -> None: pass _cache.clear() _client_cache.clear() + _discovery_failures.clear() def _get_client(purl_type: str) -> TeaClient | None: @@ -98,11 +102,17 @@ def _get_client(purl_type: str) -> TeaClient | None: cache_key = f"domain:{domain}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: + if _discovery_failures.get(cache_key, 0) >= _MAX_DISCOVERY_RETRIES: + return None try: _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) except Exception as exc: - logger.warning(f"TEA well-known discovery failed for {domain}: {exc}") - _client_cache[cache_key] = None + _discovery_failures[cache_key] = _discovery_failures.get(cache_key, 0) + 1 + logger.warning( + f"TEA well-known discovery failed for {domain} " + f"(attempt {_discovery_failures[cache_key]}/{_MAX_DISCOVERY_RETRIES}): {exc}" + ) + return None return _client_cache[cache_key] @@ -133,8 +143,11 @@ def provides_cle(self) -> bool: return True def supports(self, purl: PackageURL) -> bool: - """Supported when PURL type has a known TEA domain or TEA_BASE_URL is set.""" - return purl.type in PURL_TYPE_TO_TEA_DOMAIN or bool(os.getenv("TEA_BASE_URL")) + """Supported when PURL type has a known TEA domain or a safe TEA_BASE_URL is set.""" + if purl.type in PURL_TYPE_TO_TEA_DOMAIN: + return True + base_url = os.getenv("TEA_BASE_URL") + return bool(base_url) and _is_safe_url(base_url) def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetadata | None: """Discover TEA server from PURL type and fetch metadata.""" From db07f2373f550988205591966090ccf44a550477 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 10 Mar 2026 00:37:45 +0300 Subject: [PATCH 28/40] fix: guard None media_type, fix client_cache type, remove provides_cle from protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Guard fmt.media_type with 'or "unknown"' in download status message - Revert _client_cache type to dict[str, TeaClient] since None is never stored - Remove provides_cle from DataSource protocol — it is optional and checked via getattr at runtime, not a required protocol member --- sbomify_action/_enrichment/protocol.py | 14 -------------- sbomify_action/_enrichment/sources/tea.py | 2 +- sbomify_action/cli/tea.py | 2 +- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/sbomify_action/_enrichment/protocol.py b/sbomify_action/_enrichment/protocol.py index 39811c9d..cca44b98 100644 --- a/sbomify_action/_enrichment/protocol.py +++ b/sbomify_action/_enrichment/protocol.py @@ -57,20 +57,6 @@ def priority(self) -> int: """ ... - @property - def provides_cle(self) -> bool: - """ - Whether this source can provide CLE (Common Lifecycle Enumeration) data. - - Sources that supply cle_release_date, cle_eos, or cle_eol should - return True so the registry's early-exit logic keeps querying them - even after NTIA fields are satisfied. - - Sources that do not implement this property are treated as False - by the registry via ``getattr(source, "provides_cle", False)``. - """ - ... - def supports(self, purl: PackageURL) -> bool: """ Check if this source can handle the given PURL type. diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index a8dc146f..0945975d 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -38,7 +38,7 @@ } _cache: dict[str, NormalizedMetadata | None] = {} -_client_cache: dict[str, TeaClient | None] = {} +_client_cache: dict[str, TeaClient] = {} _discovery_failures: dict[str, int] = {} _MAX_DISCOVERY_RETRIES = 2 diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 10c4e1b4..3139dbe2 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -147,7 +147,7 @@ def fetch( if not fmt: _error(f"No downloadable format found for artifact '{artifact.name}'") - print(f"Downloading {artifact.name} ({fmt.media_type}) ...", file=sys.stderr) + print(f"Downloading {artifact.name} ({fmt.media_type or 'unknown'}) ...", file=sys.stderr) result_path = client.download_artifact(fmt.url, dest, verify_checksums=fmt.checksums) print(f"Saved to {result_path}", file=sys.stderr) From 0fdda5ccb27a49a662c50123674b72bd94f6cc55 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 10 Mar 2026 00:48:42 +0300 Subject: [PATCH 29/40] fix: rename _MAX_DISCOVERY_RETRIES to _MAX_DISCOVERY_ATTEMPTS, clarify provides_cle convention - Rename constant to accurately reflect it limits total attempts, not retries - Add inline comment in registry explaining provides_cle opt-in convention --- sbomify_action/_enrichment/registry.py | 4 +++- sbomify_action/_enrichment/sources/tea.py | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 120d3321..6f438306 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -95,7 +95,9 @@ def fetch_metadata( if result.cle_release_date and result.cle_eos and result.cle_eol: logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") break - # NTIA complete but CLE missing — only continue with lifecycle-capable sources + # NTIA complete but CLE missing — only continue with lifecycle-capable sources. + # Sources opt in by declaring `provides_cle = True` (not in the Protocol; + # checked via getattr so non-CLE sources need no changes). if not getattr(source, "provides_cle", False): logger.debug(f"Skipping {source.name} - NTIA complete, not a CLE provider for {purl.name}") continue diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 0945975d..182eedc0 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -41,7 +41,7 @@ _client_cache: dict[str, TeaClient] = {} _discovery_failures: dict[str, int] = {} -_MAX_DISCOVERY_RETRIES = 2 +_MAX_DISCOVERY_ATTEMPTS = 2 DEFAULT_TIMEOUT = 15 @@ -102,7 +102,7 @@ def _get_client(purl_type: str) -> TeaClient | None: cache_key = f"domain:{domain}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: - if _discovery_failures.get(cache_key, 0) >= _MAX_DISCOVERY_RETRIES: + if _discovery_failures.get(cache_key, 0) >= _MAX_DISCOVERY_ATTEMPTS: return None try: _client_cache[cache_key] = TeaClient.from_well_known(domain, token=token, timeout=DEFAULT_TIMEOUT) @@ -110,7 +110,7 @@ def _get_client(purl_type: str) -> TeaClient | None: _discovery_failures[cache_key] = _discovery_failures.get(cache_key, 0) + 1 logger.warning( f"TEA well-known discovery failed for {domain} " - f"(attempt {_discovery_failures[cache_key]}/{_MAX_DISCOVERY_RETRIES}): {exc}" + f"(attempt {_discovery_failures[cache_key]}/{_MAX_DISCOVERY_ATTEMPTS}): {exc}" ) return None return _client_cache[cache_key] From 6e25efbfb2f8f4db09ea063a8aeab0dbe0d81127 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 10 Mar 2026 01:03:19 +0300 Subject: [PATCH 30/40] test: add test for supports() rejecting unsafe TEA_BASE_URL --- tests/test_tea_enrichment.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index cadafb71..be0a42eb 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -55,6 +55,13 @@ def test_supports_any_type_with_base_url_override(self): with patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}): assert source.supports(purl) is True + def test_does_not_support_unsafe_base_url(self): + source = TeaSource() + purl = PackageURL.from_string("pkg:cargo/serde@1.0") + for unsafe_url in ["http://127.0.0.1/v1", "http://10.0.0.1/v1", "http://localhost/v1"]: + with patch.dict("os.environ", {"TEA_BASE_URL": unsafe_url}, clear=True): + assert source.supports(purl) is False, f"Should reject unsafe URL: {unsafe_url}" + def test_supports_all_mapped_types(self): source = TeaSource() with patch.dict("os.environ", {}, clear=True): From 4313bc17d573acf2e2e9510c993a4d125ba8a587 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 17 Mar 2026 19:14:20 +0300 Subject: [PATCH 31/40] fix: resolve mypy strict mode errors in TEA files - Add type annotations to fetch() CLI command parameters and return type - Add type: ignore[untyped-decorator] on @tea_group.command() for libtea - Add return after _error() to help mypy narrow fmt type - Assert fmt.url before download_artifact, convert checksums to list - Wrap PackageURL.to_string() with str() to fix no-any-return - Update test assertion to match tuple-to-list conversion --- sbomify_action/_enrichment/sources/tea.py | 4 +-- sbomify_action/cli/tea.py | 36 +++++++++++++---------- tests/test_tea_cli.py | 2 +- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 182eedc0..fd5f0544 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -118,7 +118,7 @@ def _get_client(purl_type: str) -> TeaClient | None: def _purl_to_search_value(purl: PackageURL) -> str: """Convert a PackageURL to a canonical string (no qualifiers/subpath).""" - return PackageURL(type=purl.type, namespace=purl.namespace, name=purl.name, version=purl.version).to_string() + return str(PackageURL(type=purl.type, namespace=purl.namespace, name=purl.name, version=purl.version).to_string()) class TeaSource: @@ -147,7 +147,7 @@ def supports(self, purl: PackageURL) -> bool: if purl.type in PURL_TYPE_TO_TEA_DOMAIN: return True base_url = os.getenv("TEA_BASE_URL") - return bool(base_url) and _is_safe_url(base_url) + return bool(base_url) and _is_safe_url(str(base_url)) def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetadata | None: """Discover TEA server from PURL type and fetch metadata.""" diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index 3139dbe2..f13b3e30 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -42,7 +42,7 @@ def _select_best_format( return None -@tea_group.command() +@tea_group.command() # type: ignore[untyped-decorator] @click.option("--tei", default=None, help="TEI URN to discover and fetch SBOM for") @click.option("--product-release-uuid", default=None, help="Product release UUID to fetch from") @click.option("--component-release-uuid", default=None, help="Component release UUID to fetch from") @@ -72,20 +72,20 @@ def _select_best_format( @click.option("--port", type=int, default=None, help="Port for well-known resolution") @click.option("--allow-private-ips", is_flag=True, help="Allow private IPs (WARNING: weakens SSRF protections)") def fetch( - tei, - product_release_uuid, - component_release_uuid, - artifact_type, - output_path, - base_url, - domain, - token, - auth, - timeout, - use_http, - port, - allow_private_ips, -): + tei: str | None, + product_release_uuid: str | None, + component_release_uuid: str | None, + artifact_type: str, + output_path: str, + base_url: str | None, + domain: str | None, + token: str | None, + auth: str | None, + timeout: float, + use_http: bool, + port: int | None, + allow_private_ips: bool, +) -> None: """Fetch an SBOM from a TEA server in one step. Combines discovery, collection lookup, artifact selection, and download. @@ -146,10 +146,14 @@ def fetch( fmt = _select_best_format(artifact.formats) if not fmt: _error(f"No downloadable format found for artifact '{artifact.name}'") + return # unreachable: _error is NoReturn, but helps mypy narrow fmt print(f"Downloading {artifact.name} ({fmt.media_type or 'unknown'}) ...", file=sys.stderr) - result_path = client.download_artifact(fmt.url, dest, verify_checksums=fmt.checksums) + assert fmt.url is not None # guaranteed by _select_best_format + result_path = client.download_artifact( + fmt.url, dest, verify_checksums=list(fmt.checksums) if fmt.checksums else None + ) print(f"Saved to {result_path}", file=sys.stderr) except TeaError as exc: diff --git a/tests/test_tea_cli.py b/tests/test_tea_cli.py index 7bc16bce..a86b745e 100644 --- a/tests/test_tea_cli.py +++ b/tests/test_tea_cli.py @@ -241,7 +241,7 @@ def test_fetch_by_component_release_uuid(self, mock_build_client): mock_client.download_artifact.assert_called_once() # Verify checksums were passed through call_kwargs = mock_client.download_artifact.call_args - assert call_kwargs.kwargs.get("verify_checksums") == checksums + assert call_kwargs.kwargs.get("verify_checksums") == list(checksums) @patch("sbomify_action.cli.tea._build_client") def test_fetch_by_product_release_uuid(self, mock_build_client): From 31644acd024d9cb756fbc58c0a0fd77ff1a1c36a Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 17 Mar 2026 19:18:48 +0300 Subject: [PATCH 32/40] fix: validate URL scheme in SSRF check and redact URLs in logs - Reject non-HTTP/HTTPS schemes (ftp, gopher, etc.) in _is_safe_url - Add _redact_url helper to strip credentials/path/query before logging - Use redacted URL in TEA_BASE_URL rejection warning - Add test for non-HTTP scheme rejection --- sbomify_action/_enrichment/sources/tea.py | 16 +++++++++++++++- tests/test_tea_enrichment.py | 5 +++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index fd5f0544..9414fd85 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -53,6 +53,8 @@ def _is_safe_url(url: str) -> bool: """Check that a URL does not point to private/internal addresses.""" try: parsed = urlparse(url) + if (parsed.scheme or "").lower() not in ("http", "https"): + return False hostname = parsed.hostname if not hostname: return False @@ -69,6 +71,18 @@ def _is_safe_url(url: str) -> bool: return False +def _redact_url(url: str) -> str: + """Return scheme://hostname:port only, stripping credentials, path, and query.""" + try: + parsed = urlparse(url) + hostname = parsed.hostname or "" + port = f":{parsed.port}" if parsed.port else "" + scheme = f"{parsed.scheme}://" if parsed.scheme else "" + return f"{scheme}{hostname}{port}" if hostname else "" + except Exception: + return "" + + def clear_cache() -> None: """Clear the module-level caches (closes cached clients).""" for client in _client_cache.values(): @@ -89,7 +103,7 @@ def _get_client(purl_type: str) -> TeaClient | None: if base_url_override: if not _is_safe_url(base_url_override): - logger.warning(f"TEA_BASE_URL rejected (private/internal address): {base_url_override}") + logger.warning(f"TEA_BASE_URL rejected (private/internal address): {_redact_url(base_url_override)}") return None cache_key = f"base_url:{base_url_override}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index be0a42eb..278b6f08 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -127,6 +127,11 @@ def test_empty_url_rejected(self): def test_no_hostname_rejected(self): assert _is_safe_url("file:///etc/passwd") is False + def test_non_http_scheme_rejected(self): + assert _is_safe_url("ftp://tea.example.com/v1") is False + assert _is_safe_url("gopher://tea.example.com/v1") is False + assert _is_safe_url("javascript://tea.example.com") is False + class TestTeaSourceFetch(unittest.TestCase): """Test fetch behavior with mocked TeaClient.""" From 14f6fc9a89c4ccc0522137cacd8343169b8d9d68 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 17 Mar 2026 19:31:51 +0300 Subject: [PATCH 33/40] fix: resolve DNS in SSRF check and update early-exit comment - Add DNS resolution to _is_safe_url to catch hostnames resolving to private IPs (e.g. nip.io-style SSRF bypass) - Extract _is_public_ip helper for reuse across IP literal and DNS checks - Update registry early-exit comment to describe two-phase logic - Add test for DNS-resolving-to-private-IP rejection - Mock socket.getaddrinfo in tests using non-resolvable hostnames --- sbomify_action/_enrichment/registry.py | 4 +++- sbomify_action/_enrichment/sources/tea.py | 27 ++++++++++++++++++---- tests/test_tea_enrichment.py | 28 +++++++++++++++++++---- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 6f438306..96e4b41d 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -90,7 +90,9 @@ def fetch_metadata( result: NormalizedMetadata | None = None for source in sources: - # Stop early if we already have all core NTIA fields + # Two-phase early exit: (1) if NTIA + all CLE fields are filled, stop entirely; + # (2) if only NTIA is filled but CLE is missing, skip non-CLE sources and + # continue only with lifecycle-capable sources (those declaring provides_cle=True). if result and result.description and result.licenses and result.supplier: if result.cle_release_date and result.cle_eos and result.cle_eol: logger.debug(f"Skipping {source.name} - already have sufficient data for {purl.name}") diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 9414fd85..e0fbdaf5 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -50,7 +50,10 @@ def _is_safe_url(url: str) -> bool: - """Check that a URL does not point to private/internal addresses.""" + """Check that a URL does not point to private/internal addresses. + + Validates scheme, blocked hostnames, IP literals, and DNS-resolved addresses. + """ try: parsed = urlparse(url) if (parsed.scheme or "").lower() not in ("http", "https"): @@ -60,17 +63,33 @@ def _is_safe_url(url: str) -> bool: return False if hostname.lower() in _BLOCKED_HOSTNAMES: return False + # Check IP literals directly try: ip = ipaddress.ip_address(hostname) - if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified: - return False + return _is_public_ip(ip) except ValueError: - pass # Not an IP literal; DNS-based SSRF (e.g. nip.io) is not checked here + pass # Not an IP literal — resolve via DNS below + # Resolve hostname and check all resulting addresses + import socket + + try: + addrinfo = socket.getaddrinfo(hostname, None, proto=socket.IPPROTO_TCP) + for _family, _type, _proto, _canonname, sockaddr in addrinfo: + ip = ipaddress.ip_address(sockaddr[0]) + if not _is_public_ip(ip): + return False + except socket.gaierror: + return False # Unresolvable hostname is not safe return True except Exception: return False +def _is_public_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: + """Return True if the IP address is public (not private/loopback/link-local/etc).""" + return not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified) + + def _redact_url(url: str) -> str: """Return scheme://hostname:port only, stripping credentials, path, and query.""" try: diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index 278b6f08..fbad8f7e 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -2,6 +2,7 @@ import unittest from datetime import datetime, timezone +from typing import Any from unittest.mock import MagicMock, patch from libtea.exceptions import TeaConnectionError, TeaNotFoundError @@ -24,6 +25,13 @@ ) +def _fake_public_getaddrinfo(*args: Any, **kwargs: Any) -> list[tuple[int, int, int, str, tuple[str, int]]]: + """Return a fake public IP for DNS resolution in tests.""" + import socket + + return [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("93.184.216.34", 0))] + + class TestTeaSourceProperties(unittest.TestCase): """Test TeaSource name and priority.""" @@ -49,7 +57,8 @@ def test_does_not_support_unmapped_type_without_env(self): with patch.dict("os.environ", {}, clear=True): assert source.supports(purl) is False - def test_supports_any_type_with_base_url_override(self): + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) + def test_supports_any_type_with_base_url_override(self, _mock_dns: Any) -> None: source = TeaSource() purl = PackageURL.from_string("pkg:cargo/serde@1.0") with patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}): @@ -97,7 +106,8 @@ def test_purl_strips_subpath(self): class TestIsSafeUrl(unittest.TestCase): """Test SSRF validation for TEA_BASE_URL.""" - def test_public_url_allowed(self): + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) + def test_public_url_allowed(self, _mock_dns: Any) -> None: assert _is_safe_url("https://tea.example.com/v1") is True def test_private_ip_rejected(self): @@ -127,6 +137,14 @@ def test_empty_url_rejected(self): def test_no_hostname_rejected(self): assert _is_safe_url("file:///etc/passwd") is False + def test_dns_resolving_to_private_ip_rejected(self) -> None: + """Hostnames that resolve to private IPs (e.g. nip.io) should be rejected.""" + import socket + + private_addrinfo = [(socket.AF_INET, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", ("10.0.0.1", 0))] + with patch("socket.getaddrinfo", return_value=private_addrinfo): + assert _is_safe_url("https://10-0-0-1.nip.io/v1") is False + def test_non_http_scheme_rejected(self): assert _is_safe_url("ftp://tea.example.com/v1") is False assert _is_safe_url("gopher://tea.example.com/v1") is False @@ -224,8 +242,9 @@ def test_fetch_auto_discovery(self, mock_client_cls): self.session.assert_not_called() @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}) + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) - def test_fetch_base_url_override(self, mock_client_cls): + def test_fetch_base_url_override(self, mock_client_cls: Any, _mock_dns: Any) -> None: """TEA_BASE_URL overrides auto-discovery.""" now = datetime.now(timezone.utc) mock_client = MagicMock() @@ -241,8 +260,9 @@ def test_fetch_base_url_override(self, mock_client_cls): mock_client_cls.from_well_known.assert_not_called() @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1", "TEA_TOKEN": "secret"}) + @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) - def test_fetch_base_url_with_token(self, mock_client_cls): + def test_fetch_base_url_with_token(self, mock_client_cls: Any, _mock_dns: Any) -> None: """TEA_BASE_URL + TEA_TOKEN are passed together.""" now = datetime.now(timezone.utc) mock_client = MagicMock() From 832bba28ff6de4c740cd18aca4079ec66254d117 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Tue, 17 Mar 2026 21:57:59 +0300 Subject: [PATCH 34/40] fix: use ip.is_global to reject multicast/broadcast in SSRF check - Replace manual is_private/is_loopback/etc checks with ip.is_global which also rejects multicast (224.0.0.0/4, ff00::/8) and other non-globally-routable ranges - Add comment explaining str() wrap on to_string() for mypy strict --- sbomify_action/_enrichment/sources/tea.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index e0fbdaf5..2713c2c3 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -86,8 +86,8 @@ def _is_safe_url(url: str) -> bool: def _is_public_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: - """Return True if the IP address is public (not private/loopback/link-local/etc).""" - return not (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_unspecified) + """Return True if the IP address is globally routable unicast.""" + return ip.is_global def _redact_url(url: str) -> str: @@ -151,6 +151,7 @@ def _get_client(purl_type: str) -> TeaClient | None: def _purl_to_search_value(purl: PackageURL) -> str: """Convert a PackageURL to a canonical string (no qualifiers/subpath).""" + # str() needed: packageurl lacks type stubs, to_string() returns Any return str(PackageURL(type=purl.type, namespace=purl.namespace, name=purl.name, version=purl.version).to_string()) From f43a8f9ef50267ca1e80a491cf4b19a272028554 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 23 Mar 2026 19:42:39 +0300 Subject: [PATCH 35/40] fix: add clear=True to base_url env patches, update fetch_metadata docstring - Add clear=True to test_fetch_base_url_override and test_fetch_base_url_with_token to prevent TEA_TOKEN env pollution - Update fetch_metadata docstring to describe two-phase early exit: NTIA+CLE complete stops entirely, NTIA-only skips non-CLE sources --- sbomify_action/_enrichment/registry.py | 9 ++++++--- tests/test_tea_enrichment.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/sbomify_action/_enrichment/registry.py b/sbomify_action/_enrichment/registry.py index 96e4b41d..80ec7761 100644 --- a/sbomify_action/_enrichment/registry.py +++ b/sbomify_action/_enrichment/registry.py @@ -70,9 +70,12 @@ def fetch_metadata( """ Fetch metadata using the priority chain of sources. - Tries sources in priority order, stopping early when we have - sufficient data (description, licenses, supplier). Only continues - to lower-priority sources if critical fields are missing. + Tries sources in priority order with two-phase early exit: + 1. If NTIA fields (description, licenses, supplier) AND all CLE + fields (release_date, eos, eol) are filled, stop entirely. + 2. If only NTIA fields are filled but CLE is missing, skip + non-CLE sources and continue only with lifecycle-capable + sources (those declaring ``provides_cle = True``). Args: purl: Parsed PackageURL object diff --git a/tests/test_tea_enrichment.py b/tests/test_tea_enrichment.py index fbad8f7e..8ef62c2d 100644 --- a/tests/test_tea_enrichment.py +++ b/tests/test_tea_enrichment.py @@ -241,7 +241,7 @@ def test_fetch_auto_discovery(self, mock_client_cls): # Session is intentionally unused (libtea manages its own HTTP transport) self.session.assert_not_called() - @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}) + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1"}, clear=True) @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_base_url_override(self, mock_client_cls: Any, _mock_dns: Any) -> None: @@ -259,7 +259,7 @@ def test_fetch_base_url_override(self, mock_client_cls: Any, _mock_dns: Any) -> mock_client_cls.assert_called_once_with("https://tea.example.com/v1", token=None, timeout=15) mock_client_cls.from_well_known.assert_not_called() - @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1", "TEA_TOKEN": "secret"}) + @patch.dict("os.environ", {"TEA_BASE_URL": "https://tea.example.com/v1", "TEA_TOKEN": "secret"}, clear=True) @patch("socket.getaddrinfo", side_effect=_fake_public_getaddrinfo) @patch("sbomify_action._enrichment.sources.tea.TeaClient", autospec=True) def test_fetch_base_url_with_token(self, mock_client_cls: Any, _mock_dns: Any) -> None: From c02009110ddc225fd77c6d5bd0041711689b8a8f Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 23 Mar 2026 19:51:59 +0300 Subject: [PATCH 36/40] fix: cache SSRF check, resilient libtea imports, supports() checks override first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move _is_safe_url call inside cache-miss branch to avoid redundant DNS resolution on every _get_client invocation - Import libtea private helpers (_build_client, _error) via getattr with fallback functions that provide clear error messages if API changes - Check TEA_BASE_URL safety first in supports() — unsafe override now returns False for all PURL types instead of only unmapped ones --- sbomify_action/_enrichment/sources/tea.py | 14 ++++++++------ sbomify_action/cli/tea.py | 23 ++++++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index 2713c2c3..a05a73a8 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -121,11 +121,11 @@ def _get_client(purl_type: str) -> TeaClient | None: base_url_override = os.getenv("TEA_BASE_URL") if base_url_override: - if not _is_safe_url(base_url_override): - logger.warning(f"TEA_BASE_URL rejected (private/internal address): {_redact_url(base_url_override)}") - return None cache_key = f"base_url:{base_url_override}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" if cache_key not in _client_cache: + if not _is_safe_url(base_url_override): + logger.warning(f"TEA_BASE_URL rejected (private/internal address): {_redact_url(base_url_override)}") + return None _client_cache[cache_key] = TeaClient(base_url_override, token=token, timeout=DEFAULT_TIMEOUT) return _client_cache[cache_key] @@ -178,10 +178,12 @@ def provides_cle(self) -> bool: def supports(self, purl: PackageURL) -> bool: """Supported when PURL type has a known TEA domain or a safe TEA_BASE_URL is set.""" - if purl.type in PURL_TYPE_TO_TEA_DOMAIN: - return True base_url = os.getenv("TEA_BASE_URL") - return bool(base_url) and _is_safe_url(str(base_url)) + if base_url: + # If an override is present but unsafe, treat TEA as unsupported + # to avoid wasted calls and log spam. + return _is_safe_url(str(base_url)) + return purl.type in PURL_TYPE_TO_TEA_DOMAIN def fetch(self, purl: PackageURL, session: requests.Session) -> NormalizedMetadata | None: """Discover TEA server from PURL type and fetch metadata.""" diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index f13b3e30..ebda4eef 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -7,17 +7,30 @@ import sys from collections.abc import Sequence from pathlib import Path +from typing import Any import click - -# _build_client and _error are private helpers in libtea's CLI module. -# We own libtea and intentionally couple to these; they are stable. -# _error() is typed NoReturn and raises SystemExit(1). -from libtea.cli import _build_client, _error +import libtea.cli as _libtea_cli from libtea.cli import app as tea_group from libtea.exceptions import TeaError from libtea.models import ArtifactFormat, ArtifactType +# Bind private helpers via the module to avoid hard-failing imports if a +# future libtea version removes them. We own libtea and these are stable, +# but this provides a clear error message if the API changes. +try: + _build_client = _libtea_cli._build_client # type: ignore[attr-defined] + _error = _libtea_cli._error # type: ignore[attr-defined] +except AttributeError as _exc: + _compat_err = str(_exc) + + def _error(message: str) -> None: # type: ignore[misc] + raise SystemExit(f"Incompatible libtea version: {_compat_err}. {message}") + + def _build_client(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc] + _error("Required helper _build_client is missing from libtea.cli.") + + __all__ = ["tea_group"] _BOM_MEDIA_TYPES = ( From d8be4d1cdd8ffd15ff312ad7dac94cde831f0aa0 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 23 Mar 2026 19:59:30 +0300 Subject: [PATCH 37/40] fix: cache _is_safe_url results to avoid repeated DNS resolution Split _is_safe_url into a cached wrapper and _check_url_safety for the actual check. Results are memoized per URL in _url_safety_cache, making supports() O(1) after the first call. Cache is cleared by clear_cache(). --- sbomify_action/_enrichment/sources/tea.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index a05a73a8..fcae540e 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -40,6 +40,7 @@ _cache: dict[str, NormalizedMetadata | None] = {} _client_cache: dict[str, TeaClient] = {} _discovery_failures: dict[str, int] = {} +_url_safety_cache: dict[str, bool] = {} _MAX_DISCOVERY_ATTEMPTS = 2 @@ -50,10 +51,20 @@ def _is_safe_url(url: str) -> bool: - """Check that a URL does not point to private/internal addresses. + """Check that a URL does not point to private/internal addresses (cached). Validates scheme, blocked hostnames, IP literals, and DNS-resolved addresses. + Results are cached per URL to avoid repeated DNS resolution. """ + if url in _url_safety_cache: + return _url_safety_cache[url] + result = _check_url_safety(url) + _url_safety_cache[url] = result + return result + + +def _check_url_safety(url: str) -> bool: + """Perform the actual URL safety check (uncached).""" try: parsed = urlparse(url) if (parsed.scheme or "").lower() not in ("http", "https"): @@ -113,6 +124,7 @@ def clear_cache() -> None: _cache.clear() _client_cache.clear() _discovery_failures.clear() + _url_safety_cache.clear() def _get_client(purl_type: str) -> TeaClient | None: From 953670328a6a90906810bc93dba50d152c3d96db Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 23 Mar 2026 20:47:52 +0300 Subject: [PATCH 38/40] fix: update create_default_registry docstring for two-phase early exit --- sbomify_action/_enrichment/enricher.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/sbomify_action/_enrichment/enricher.py b/sbomify_action/_enrichment/enricher.py index 096161b4..f9fe3893 100644 --- a/sbomify_action/_enrichment/enricher.py +++ b/sbomify_action/_enrichment/enricher.py @@ -66,9 +66,12 @@ def create_default_registry() -> SourceRegistry: - ClearlyDefinedSource (75) - license and attribution data - RepologySource (90) - cross-distro metadata (rate-limited) - Sources are queried sequentially in priority order. If a source returns - all required NTIA fields (description, licenses, supplier), subsequent - sources are skipped. + Sources are queried sequentially in priority order with two-phase early exit: + 1. If NTIA fields (description, licenses, supplier) AND all CLE fields + (release_date, eos, eol) are filled, remaining sources are skipped. + 2. If only NTIA fields are filled but CLE is missing, non-CLE sources + are skipped while lifecycle-capable sources (provides_cle=True) + continue to run. Returns: Configured SourceRegistry From 902deb6db8e333e4494b0fdcc302706a1a39d1c2 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 23 Mar 2026 21:05:24 +0300 Subject: [PATCH 39/40] fix: replace assert with explicit _error check for fmt.url --- sbomify_action/cli/tea.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sbomify_action/cli/tea.py b/sbomify_action/cli/tea.py index ebda4eef..4698435c 100644 --- a/sbomify_action/cli/tea.py +++ b/sbomify_action/cli/tea.py @@ -161,9 +161,12 @@ def fetch( _error(f"No downloadable format found for artifact '{artifact.name}'") return # unreachable: _error is NoReturn, but helps mypy narrow fmt + if not fmt.url: + _error(f"Selected format for artifact '{artifact.name}' has no download URL") + return # unreachable: _error is NoReturn, but helps mypy narrow fmt.url + print(f"Downloading {artifact.name} ({fmt.media_type or 'unknown'}) ...", file=sys.stderr) - assert fmt.url is not None # guaranteed by _select_best_format result_path = client.download_artifact( fmt.url, dest, verify_checksums=list(fmt.checksums) if fmt.checksums else None ) From 504f5689cf12ac4897587bc873abda210ee35528 Mon Sep 17 00:00:00 2001 From: Rana Aurangzaib Date: Mon, 23 Mar 2026 21:14:07 +0300 Subject: [PATCH 40/40] fix: hash base URL in client cache key to avoid retaining credentials --- sbomify_action/_enrichment/sources/tea.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sbomify_action/_enrichment/sources/tea.py b/sbomify_action/_enrichment/sources/tea.py index fcae540e..4e76a523 100644 --- a/sbomify_action/_enrichment/sources/tea.py +++ b/sbomify_action/_enrichment/sources/tea.py @@ -133,7 +133,9 @@ def _get_client(purl_type: str) -> TeaClient | None: base_url_override = os.getenv("TEA_BASE_URL") if base_url_override: - cache_key = f"base_url:{base_url_override}:token:{hashlib.sha256((token or '').encode()).hexdigest()[:16]}" + url_hash = hashlib.sha256(base_url_override.encode()).hexdigest()[:16] + token_hash = hashlib.sha256((token or "").encode()).hexdigest()[:16] + cache_key = f"base_url:{url_hash}:token:{token_hash}" if cache_key not in _client_cache: if not _is_safe_url(base_url_override): logger.warning(f"TEA_BASE_URL rejected (private/internal address): {_redact_url(base_url_override)}")