Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -98,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
Expand Down
2 changes: 2 additions & 0 deletions osv/ecosystems/_ecosystems.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +61,7 @@
'PyPI': PyPI,
'Red Hat': RPM,
'Rocky Linux': RPM,
'Root': Root,
'RubyGems': RubyGems,
'SUSE': RPM,
'SwiftURL': SemverEcosystem,
Expand Down
34 changes: 34 additions & 0 deletions osv/ecosystems/_ecosystems_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
115 changes: 115 additions & 0 deletions osv/ecosystems/root.py
Original file line number Diff line number Diff line change
@@ -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: <version>-r<patch_number> (e.g., 1.0.0-r10071)
- Python: <version>+root.io.<patch_number> (e.g., 1.0.0+root.io.1)
- Others: <version>.root.io.<patch_number> (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: <version>-r<number>
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: <version>+root.io.<number>
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: <version>.root.io.<number>
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,)
2 changes: 2 additions & 0 deletions osv/purl_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
EcosystemPURL('rpm', 'redhat'),
'Rocky Linux':
EcosystemPURL('rpm', 'rocky-linux'),
'Root':
EcosystemPURL('generic', 'root'),
'RubyGems':
EcosystemPURL('gem', None),
'SUSE':
Expand Down
12 changes: 12 additions & 0 deletions osv/purl_helpers_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))

Expand Down Expand Up @@ -285,6 +291,12 @@ def test_parse_purl(self):
('Rocky Linux', 'test-package', '1.2.3'),
purl_helpers.parse_purl('pkg:rpm/rocky-linux/[email protected]'))

self.assertEqual(('Root', 'root-nginx', '1.0.0-r10071'),
purl_helpers.parse_purl('pkg:generic/root/[email protected]'))

self.assertEqual(('Root', '@root/lodash', '4.17.21'),
purl_helpers.parse_purl('pkg:generic/root/%40root%[email protected]'))

self.assertEqual(('RubyGems', 'test-package', '1.2.3'),
purl_helpers.parse_purl('pkg:gem/[email protected]'))

Expand Down
15 changes: 15 additions & 0 deletions source.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
15 changes: 15 additions & 0 deletions source_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down