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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Supported data sources:
- SLES (https://ftp.suse.com/pub/projects/security/oval)
- Ubuntu (https://launchpad.net/ubuntu-cve-tracker)
- Wolfi (https://packages.wolfi.dev)
- Root (https://api.root.io/external/cve_feed)


## Installation
Expand Down Expand Up @@ -56,17 +57,23 @@ List the available vulnerability data providers:
```
$ vunnel list

alma
alpine
amazon
bitnami
chainguard
debian
echo
epss
github
kev
mariner
minimos
nvd
oracle
rhel
rocky
rootio
sles
ubuntu
wolfi
Expand Down
1 change: 1 addition & 0 deletions src/vunnel/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Providers:
oracle: providers.oracle.Config = field(default_factory=providers.oracle.Config)
rhel: providers.rhel.Config = field(default_factory=providers.rhel.Config)
rocky: providers.rocky.Config = field(default_factory=providers.rocky.Config)
rootio: providers.rootio.Config = field(default_factory=providers.rootio.Config)
sles: providers.sles.Config = field(default_factory=providers.sles.Config)
ubuntu: providers.ubuntu.Config = field(default_factory=providers.ubuntu.Config)
wolfi: providers.wolfi.Config = field(default_factory=providers.wolfi.Config)
Expand Down
2 changes: 2 additions & 0 deletions src/vunnel/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
oracle,
rhel,
rocky,
rootio,
sles,
ubuntu,
wolfi,
Expand All @@ -43,6 +44,7 @@
oracle.Provider.name(): oracle.Provider,
rhel.Provider.name(): rhel.Provider,
rocky.Provider.name(): rocky.Provider,
rootio.Provider.name(): rootio.Provider,
sles.Provider.name(): sles.Provider,
ubuntu.Provider.name(): ubuntu.Provider,
wolfi.Provider.name(): wolfi.Provider,
Expand Down
60 changes: 60 additions & 0 deletions src/vunnel/providers/rootio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations

import os
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from vunnel import provider, result, schema

from .parser import Parser

if TYPE_CHECKING:
import datetime


@dataclass
class Config:
runtime: provider.RuntimeConfig = field(
default_factory=lambda: provider.RuntimeConfig(
result_store=result.StoreStrategy.SQLITE,
existing_results=result.ResultStatePolicy.DELETE_BEFORE_WRITE,
),
)
request_timeout: int = 125


class Provider(provider.Provider):
__schema__ = schema.OSSchema()
__distribution_version__ = int(__schema__.major_version)

_url = "https://api.root.io/external/cve_feed"

def __init__(self, root: str, config: Config | None = None):
if not config:
config = Config()
super().__init__(root, runtime_cfg=config.runtime)
self.config = config

self.parser = Parser(
workspace=self.workspace,
url=self._url,
download_timeout=self.config.request_timeout,
logger=self.logger,
)

@classmethod
def name(cls) -> str:
return "rootio"

def update(self, last_updated: datetime.datetime | None) -> tuple[list[str], int]:
count = 0
with self.results_writer() as writer:
for namespace, vuln_id, record in self.parser.get():
writer.write(
identifier=os.path.join(namespace, vuln_id.lower()),
schema=self.__schema__,
payload=record,
)
count += 1

return [self._url], count
151 changes: 151 additions & 0 deletions src/vunnel/providers/rootio/parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
from __future__ import annotations

import copy
import logging
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any

import orjson

from vunnel.utils import http_wrapper as http
from vunnel.utils import vulnerability

if TYPE_CHECKING:
from collections.abc import Generator

from vunnel import workspace


class Parser:
_data_dir = "rootio-data"
_data_filename = "cve_feed.json"

# Version format mapping for different distributions
# Add new OS support by adding entries here
_VERSION_FORMAT_MAP = {
"alpine": "apk",
"rhel": "rpm",
"centos": "rpm",
"rocky": "rpm",
"alma": "rpm",
"fedora": "rpm",
"suse": "rpm",
"opensuse": "rpm",
# Default is "dpkg" for debian, ubuntu, etc.
}

def __init__(
self,
workspace: workspace.Workspace,
url: str,
download_timeout: int = 125,
logger: logging.Logger | None = None,
):
self.download_timeout = download_timeout
self.data_dir_path = Path(workspace.input_path) / self._data_dir
self.url = url

if not logger:
logger = logging.getLogger(self.__class__.__name__)
self.logger = logger

def _download(self) -> None:
if not os.path.exists(self.data_dir_path):
os.makedirs(self.data_dir_path, exist_ok=True)

try:
self.logger.info(f"downloading Root.io CVE feed from {self.url}")
r = http.get(self.url, self.logger, stream=True, timeout=self.download_timeout)
file_path = self.data_dir_path / self._data_filename
with open(file_path, "wb") as fp:
for chunk in r.iter_content():
fp.write(chunk)
except Exception:
self.logger.exception(f"Error downloading Root.io data from {self.url}")
raise

def _normalize(self, distro_name: str, distro_data: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Transform Root.io data into OS schema format with unaffected package indicators"""
vuln_dict = {}

distro_version = distro_data.get("distroversion", "unknown")
namespace = f"rootio:distro:{distro_name}:{distro_version}"

for package_data in distro_data.get("packages", []):
pkg_info = package_data.get("pkg", {})
package_name = pkg_info.get("name", "")

for cve_id, cve_info in pkg_info.get("cves", {}).items():
if cve_id not in vuln_dict:
record = copy.deepcopy(vulnerability.vulnerability_element)
record["Vulnerability"]["Name"] = cve_id
record["Vulnerability"]["NamespaceName"] = namespace

# Build reference links
reference_links = vulnerability.build_reference_links(cve_id)
record["Vulnerability"]["Link"] = reference_links[0] if reference_links else ""

record["Vulnerability"]["Severity"] = "Unknown"
record["Vulnerability"]["Description"] = f"Vulnerability {cve_id} in {package_name}"
record["Vulnerability"]["FixedIn"] = []
record["Vulnerability"]["Metadata"] = {
"CVE": [{"Name": cve_id, "Link": reference_links[0] if reference_links else ""}],
}
vuln_dict[cve_id] = record

# Add fixed version info
cve_record = vuln_dict[cve_id]
fixed_versions = cve_info.get("fixed_versions", [])

# Determine version format based on distro
version_format = self._get_version_format(distro_name)

# Add the fixed versions
# Note: Root.io uses different suffixes for different distros:
# - Debian/Ubuntu: .root.io suffix (e.g., 1.5.2-6+deb12u1.root.io.4)
# - Alpine: -rXX007X suffix (e.g., -r00071, -r10074)
for fixed_version in fixed_versions:
cve_record["Vulnerability"]["FixedIn"].append({
"Name": package_name,
"Version": fixed_version,
"VersionFormat": version_format,
"NamespaceName": namespace,
"VendorAdvisory": {"NoAdvisory": True},
})

# If no fixed versions, add unfixed entry
if not fixed_versions:
cve_record["Vulnerability"]["FixedIn"].append({
"Name": package_name,
"Version": "", # Empty version indicates no fix available
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we had discussed at #863 (comment) that this vulnerability feed only supports records of fixes? Should this code ever be reachable?

"VersionFormat": version_format,
"NamespaceName": namespace,
"VendorAdvisory": {"NoAdvisory": True},
})

return vuln_dict

def _get_version_format(self, distro_name: str) -> str:
"""Map distro name to version format."""
return self._VERSION_FORMAT_MAP.get(distro_name, "dpkg")

def get(self) -> Generator[tuple[str, str, dict[str, Any]], None, None]:
"""Download, parse and yield Root.io vulnerability records"""
# Download the data
self._download()

# Load the JSON data
with open(self.data_dir_path / self._data_filename) as fh:
feed_data = orjson.loads(fh.read())

# Process each distribution
for distro_name, distro_list in feed_data.items():
for distro_data in distro_list:
distro_version = distro_data.get("distroversion", "unknown")
namespace = f"rootio:distro:{distro_name}:{distro_version}"

vuln_records = self._normalize(distro_name, distro_data)

for vuln_id, record in vuln_records.items():
yield namespace, vuln_id, record
18 changes: 18 additions & 0 deletions tests/quality/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -345,3 +345,21 @@ tests:
- wolfi:distro:wolfi:rolling
validations:
- *default-validations

- provider: rootio
additional_providers:
- name: nvd
use_cache: true
images:
- docker.io/alpine:3.17@sha256:f271e74b17ced29b915d351685fd4644785c6d1559dd1f2d4189a5e851ef753a
- docker.io/debian:11@sha256:e538a2f0566efc44db21503277c7312a142f4d0dedc5d2886932b92626104bff
expected_namespaces:
- rootio:distro:alpine:3.17
- rootio:distro:alpine:3.18
- rootio:distro:alpine:3.19
- rootio:distro:debian:11
- rootio:distro:debian:12
- rootio:distro:ubuntu:20.04
- rootio:distro:ubuntu:22.04
validations:
- *default-validations
17 changes: 17 additions & 0 deletions tests/unit/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,23 @@ def test_config(monkeypatch) -> None:
result_store: sqlite
skip_download: false
skip_newer_archive_check: false
rootio:
request_timeout: 125
runtime:
existing_input: keep
existing_results: delete-before-write
import_results_enabled: false
import_results_host: ''
import_results_path: providers/{provider_name}/listing.json
on_error:
action: fail
input: keep
results: keep
retry_count: 3
retry_delay: 5
result_store: sqlite
skip_download: false
skip_newer_archive_check: false
sles:
allow_versions:
- '11'
Expand Down
1 change: 1 addition & 0 deletions tests/unit/providers/rootio/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Root.io provider tests
66 changes: 66 additions & 0 deletions tests/unit/providers/rootio/test-fixtures/sample_feed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{
"alpine": [
{
"distroversion": "3.17",
"packages": [
{
"pkg": {
"name": "libssl3",
"cves": {
"CVE-2023-0464": {
"fixed_versions": ["3.0.8-r4"]
},
"CVE-2023-0465": {
"fixed_versions": ["3.0.8-r4"]
}
}
}
},
{
"pkg": {
"name": "openssl",
"cves": {
"CVE-2023-0464": {
"fixed_versions": ["3.0.8-r4"]
}
}
}
}
]
}
],
"debian": [
{
"distroversion": "11",
"packages": [
{
"pkg": {
"name": "libgcrypt20",
"cves": {
"CVE-2021-40528": {
"fixed_versions": ["1.8.7-6+deb11u1"]
}
}
}
}
]
}
],
"ubuntu": [
{
"distroversion": "22.04",
"packages": [
{
"pkg": {
"name": "python3-pip",
"cves": {
"CVE-2023-5752": {
"fixed_versions": ["22.0.2+dfsg-1ubuntu0.4"]
}
}
}
}
]
}
]
}
Loading