From 670522dd08fee73a2e46d3ce37a0966c9558256c Mon Sep 17 00:00:00 2001 From: Chai Tadmor Date: Tue, 25 Nov 2025 16:00:22 +0200 Subject: [PATCH 1/2] Root data source --- docs/data.md | 2 ++ source.yaml | 15 +++++++++++++++ source_test.yaml | 15 +++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/docs/data.md b/docs/data.md index cf1cea63525..bf4eac54832 100644 --- a/docs/data.md +++ b/docs/data.md @@ -56,6 +56,8 @@ The following ecosystems have vulnerabilities encoded in this format: ([Apache 2.0](https://github.com/bitnami/vulndb/blob/main/LICENSE.md)) - [Haskell Security Advisory DB](https://github.com/haskell/security-advisories) ([CC0 1.0](https://github.com/haskell/security-advisories/blob/main/LICENSE.txt)) +- [Root](https://api.root.io/external/osv/all.json) + (License TBD) - [Ubuntu](https://github.com/canonical/ubuntu-security-notices) ([CC-BY-SA 4.0](https://github.com/canonical/ubuntu-security-notices/blob/main/LICENSE)) diff --git a/source.yaml b/source.yaml index f68d653d780..98f2bfe2099 100644 --- a/source.yaml +++ b/source.yaml @@ -101,6 +101,21 @@ editable: False strict_validation: False +- name: 'root' + versions_from_repo: False + type: 2 + rest_api_url: 'https://api.root.io/external/osv/all.json' + ignore_patterns: ['^(?!ROOT-).*$'] + directory_path: 'osv' + detect_cherrypicks: False + extension: '.json' + db_prefix: ['ROOT-'] + ignore_git: True + human_link: 'https://root.io/security/{{ BUG_ID }}' + link: 'https://api.root.io/external/osv/' + editable: False + strict_validation: True + - name: 'chainguard' versions_from_repo: False rest_api_url: 'https://packages.cgr.dev/chainguard/osv/all.json' diff --git a/source_test.yaml b/source_test.yaml index deb2dbd72d1..e3f0fd7bc26 100644 --- a/source_test.yaml +++ b/source_test.yaml @@ -101,6 +101,21 @@ editable: False strict_validation: True +- name: 'root' + versions_from_repo: False + type: 2 + rest_api_url: 'https://api.root.io/external/osv/all.json' + ignore_patterns: ['^(?!ROOT-).*$'] + directory_path: 'osv' + detect_cherrypicks: False + extension: '.json' + db_prefix: ['ROOT-'] + ignore_git: True + human_link: 'https://root.io/security/{{ BUG_ID }}' + link: 'https://api.root.io/external/osv/' + editable: False + strict_validation: True + - name: 'chainguard' versions_from_repo: False rest_api_url: 'https://packages.cgr.dev/chainguard/osv/all.json' From 0b38054f30454e559ada8c90eb078f453475dc99 Mon Sep 17 00:00:00 2001 From: Chai Tadmor Date: Wed, 26 Nov 2025 12:01:40 +0200 Subject: [PATCH 2/2] Add Root Ecosystem --- docs/data.md | 1 + osv/ecosystems/_ecosystems.py | 2 + osv/ecosystems/_ecosystems_test.py | 34 +++++++++ osv/ecosystems/root.py | 115 +++++++++++++++++++++++++++++ osv/purl_helpers.py | 2 + osv/purl_helpers_test.py | 12 +++ 6 files changed, 166 insertions(+) create mode 100644 osv/ecosystems/root.py diff --git a/docs/data.md b/docs/data.md index bf4eac54832..a294e4da28f 100644 --- a/docs/data.md +++ b/docs/data.md @@ -100,6 +100,7 @@ Between the data served in OSV and the data converted to OSV the following ecosy - Python - R (CRAN and Bioconductor) - Rocky Linux +- Root - RubyGems - SwiftURL - Ubuntu OS diff --git a/osv/ecosystems/_ecosystems.py b/osv/ecosystems/_ecosystems.py index 43402aad310..652bf0845a8 100644 --- a/osv/ecosystems/_ecosystems.py +++ b/osv/ecosystems/_ecosystems.py @@ -27,6 +27,7 @@ from .pub import Pub from .pypi import PyPI from .redhat import RPM +from .root import Root from .rubygems import RubyGems from .semver_ecosystem_helper import SemverEcosystem, SemverLike from .ubuntu import Ubuntu @@ -60,6 +61,7 @@ 'PyPI': PyPI, 'Red Hat': RPM, 'Rocky Linux': RPM, + 'Root': Root, 'RubyGems': RubyGems, 'SUSE': RPM, 'SwiftURL': SemverEcosystem, diff --git a/osv/ecosystems/_ecosystems_test.py b/osv/ecosystems/_ecosystems_test.py index a4bad8bbb4a..19f2dcbec12 100644 --- a/osv/ecosystems/_ecosystems_test.py +++ b/osv/ecosystems/_ecosystems_test.py @@ -53,3 +53,37 @@ def test_maybe_normalize_package_names(self): actual = ecosystems.maybe_normalize_package_names(package_name, ecosystem) self.assertEqual(actual, expected) + + def test_root_ecosystem(self): + """Test Root ecosystem""" + # Test that Root ecosystem is recognized + self.assertTrue(ecosystems.is_known('Root')) + self.assertTrue(ecosystems.is_known('Root:Alpine:3.18')) + self.assertTrue(ecosystems.is_known('Root:Debian:12')) + self.assertTrue(ecosystems.is_known('Root:PyPI')) + + # Test that Root ecosystem can be retrieved + root = ecosystems.get('Root') + self.assertIsNotNone(root) + + # Test version sorting for different Root version formats + root_alpine = ecosystems.get('Root:Alpine:3.18') + self.assertIsNotNone(root_alpine) + + # Alpine format: -rXXXXX + self.assertLess(root_alpine.sort_key('1.0.0-r10071'), + root_alpine.sort_key('1.0.0-r10072')) + self.assertLess(root_alpine.sort_key('1.0.0-r10071'), + root_alpine.sort_key('2.0.0-r10071')) + + # Python format: +root.io.X + root_pypi = ecosystems.get('Root:PyPI') + self.assertIsNotNone(root_pypi) + self.assertLess(root_pypi.sort_key('1.0.0+root.io.1'), + root_pypi.sort_key('1.0.0+root.io.2')) + + # Other format: .root.io.X + root_debian = ecosystems.get('Root:Debian:12') + self.assertIsNotNone(root_debian) + self.assertLess(root_debian.sort_key('1.0.0.root.io.1'), + root_debian.sort_key('1.0.0.root.io.2')) diff --git a/osv/ecosystems/root.py b/osv/ecosystems/root.py new file mode 100644 index 00000000000..653d08be08d --- /dev/null +++ b/osv/ecosystems/root.py @@ -0,0 +1,115 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Root ecosystem helper.""" + +import re +from .ecosystems_base import OrderedEcosystem + + +class Root(OrderedEcosystem): + """Root container security ecosystem. + + Root provides patched container images across multiple base distributions + and application ecosystems. The ecosystem uses hierarchical variants: + - Root:Alpine:3.18 - Alpine Linux 3.18 based images + - Root:Debian:12 - Debian 12 based images + - Root:Ubuntu:22.04 - Ubuntu 22.04 based images + - Root:PyPI - Python packages + - Root:npm - npm packages + + Version formats: + - Alpine: -r (e.g., 1.0.0-r10071) + - Python: +root.io. (e.g., 1.0.0+root.io.1) + - Others: .root.io. (e.g., 1.0.0.root.io.1) + """ + + def _sort_key(self, version: str): + """Generate sort key for Root version strings. + + Handles multiple version formats: + - Alpine: 1.0.0-r10071 + - Python: 1.0.0+root.io.1 + - Others: 1.0.0.root.io.1 + + Args: + version: Version string to parse + + Returns: + Tuple suitable for sorting + """ + # Try Alpine format: -r + alpine_match = re.match(r'^(.+?)-r(\d+)$', version) + if alpine_match: + upstream = alpine_match.group(1) + root_patch = int(alpine_match.group(2)) + return self._parse_upstream_version(upstream) + (root_patch,) + + # Try Python format: +root.io. + python_match = re.match(r'^(.+?)\+root\.io\.(\d+)$', version) + if python_match: + upstream = python_match.group(1) + root_patch = int(python_match.group(2)) + return self._parse_upstream_version(upstream) + (root_patch,) + + # Try other format: .root.io. + other_match = re.match(r'^(.+?)\.root\.io\.(\d+)$', version) + if other_match: + upstream = other_match.group(1) + root_patch = int(other_match.group(2)) + return self._parse_upstream_version(upstream) + (root_patch,) + + # Fallback: treat as generic version + return self._parse_upstream_version(version) + + def _parse_upstream_version(self, version: str): + """Parse upstream version component. + + Attempts to extract numeric and string components for sorting. + + Args: + version: Upstream version string + + Returns: + Tuple of parsed components + """ + parts = [] + + # Split on common delimiters + components = re.split(r'[.-]', version) + + for component in components: + # Try to parse as integer + try: + parts.append(int(component)) + except ValueError: + # If not numeric, use string comparison + # Convert to tuple of character codes for consistent sorting + parts.append(component) + + return tuple(parts) + + def sort_key(self, version: str): + """Public sort key method. + + Args: + version: Version string + + Returns: + Tuple for sorting + """ + try: + return self._sort_key(version) + except Exception: + # Fallback to string comparison if parsing fails + return (version,) diff --git a/osv/purl_helpers.py b/osv/purl_helpers.py index b5e6741c2ef..21bc16658ad 100644 --- a/osv/purl_helpers.py +++ b/osv/purl_helpers.py @@ -87,6 +87,8 @@ EcosystemPURL('rpm', 'redhat'), 'Rocky Linux': EcosystemPURL('rpm', 'rocky-linux'), + 'Root': + EcosystemPURL('generic', 'root'), 'RubyGems': EcosystemPURL('gem', None), 'SUSE': diff --git a/osv/purl_helpers_test.py b/osv/purl_helpers_test.py index 42a62c34e5e..1ca8276bbe2 100644 --- a/osv/purl_helpers_test.py +++ b/osv/purl_helpers_test.py @@ -134,6 +134,12 @@ def tests_package_to_purl(self): 'pkg:rpm/rocky-linux/test-package', purl_helpers.package_to_purl('Rocky Linux', 'test-package')) + self.assertEqual('pkg:generic/root/root-nginx', + purl_helpers.package_to_purl('Root', 'root-nginx')) + + self.assertEqual('pkg:generic/root/%40root%2Flodash', + purl_helpers.package_to_purl('Root', '@root/lodash')) + self.assertEqual('pkg:gem/test-package', purl_helpers.package_to_purl('RubyGems', 'test-package')) @@ -285,6 +291,12 @@ def test_parse_purl(self): ('Rocky Linux', 'test-package', '1.2.3'), purl_helpers.parse_purl('pkg:rpm/rocky-linux/test-package@1.2.3')) + self.assertEqual(('Root', 'root-nginx', '1.0.0-r10071'), + purl_helpers.parse_purl('pkg:generic/root/root-nginx@1.0.0-r10071')) + + self.assertEqual(('Root', '@root/lodash', '4.17.21'), + purl_helpers.parse_purl('pkg:generic/root/%40root%2Flodash@4.17.21')) + self.assertEqual(('RubyGems', 'test-package', '1.2.3'), purl_helpers.parse_purl('pkg:gem/test-package@1.2.3'))