Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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