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
502 changes: 502 additions & 0 deletions schema/vulnerability/osv/schema-1.7.5.json

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It's probably a good idea to delete the older OSV schema

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the suggestion. I'll take a look but I think there are old records that refer to old schemas and we'll just host the old schemas forever.

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/vunnel/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class Providers:
alpine: providers.alpine.Config = field(default_factory=providers.alpine.Config)
amazon: providers.amazon.Config = field(default_factory=providers.amazon.Config)
arch: providers.arch.Config = field(default_factory=providers.arch.Config)
bellsoft: providers.bellsoft.Config = field(default_factory=providers.bellsoft.Config)
bitnami: providers.bitnami.Config = field(default_factory=providers.bitnami.Config)
chainguard: providers.chainguard.Config = field(default_factory=providers.chainguard.Config)
chainguard_libraries: providers.chainguard_libraries.Config = field(default_factory=providers.chainguard_libraries.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 @@ -11,6 +11,7 @@
alpine,
amazon,
arch,
bellsoft,
bitnami,
chainguard,
chainguard_libraries,
Expand Down Expand Up @@ -47,6 +48,7 @@
alpine.Provider.name(): alpine.Provider,
amazon.Provider.name(): amazon.Provider,
arch.Provider.name(): arch.Provider,
bellsoft.Provider.name(): bellsoft.Provider,
bitnami.Provider.name(): bitnami.Provider,
debian.Provider.name(): debian.Provider,
echo.Provider.name(): echo.Provider,
Expand Down
87 changes: 87 additions & 0 deletions src/vunnel/providers/bellsoft/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
from __future__ import annotations

from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from vunnel import provider, result, schema
from vunnel.utils import timer

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):
# pin to the newest released OSV schema (vendored in schema/vulnerability/osv/):
# 1.x schema releases are additive, so the newest schema validates records
# authored against any older 1.x revision, but not vice versa
__schema__ = schema.OSVSchema(version="1.7.5")
__distribution_version__ = int(__schema__.major_version)

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.logger.debug(f"config: {config}")

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

# the parser refreshes its own input (fresh archive download each run),
# so the framework must not delete input state out from under it
provider.disallow_existing_input_policy(config.runtime)

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

@classmethod
def tags(cls) -> list[str]:
return ["vulnerability", "os"]

@classmethod
def compatible_schema(cls, schema_version: str) -> schema.Schema | None:
# a record's declared schema_version is metadata about when upstream
# authored it, not which schema we validate against: same-major records
# all validate under the provider's pinned schema (1.x revisions are
# additive), and the envelope URL must point at a schema file vunnel
# actually ships (e.g. upstream declares 1.6.7, which we don't vendor)
if schema.OSVSchema(schema_version).major_version == cls.__schema__.major_version:
return cls.__schema__
return None

def update(self, last_updated: datetime.datetime | None) -> tuple[list[str], int]:
with timer(self.name(), self.logger):
with self.results_writer() as writer, self.parser:
for vuln_id, vuln_schema_version, record in self.parser.get():
vuln_schema = self.compatible_schema(vuln_schema_version)
if not vuln_schema:
self.logger.warning(
f"skipping vulnerability {vuln_id} with schema version {vuln_schema_version} "
f"as it is incompatible with provider schema version {self.__schema__.version}",
)
continue
writer.write(
identifier=vuln_id.lower(),
schema=vuln_schema,
payload=record,
)

return self.parser.urls, len(writer)
151 changes: 151 additions & 0 deletions src/vunnel/providers/bellsoft/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
import tarfile
from typing import TYPE_CHECKING, Any

import orjson

from vunnel.tool import fixdate
from vunnel.utils import http_wrapper as http
from vunnel.utils import osv

if TYPE_CHECKING:
from collections.abc import Generator
from types import TracebackType

from vunnel.workspace import Workspace

# Default CVSS vector-string prefix to prepend for each OSV severity type when
# the score does not already carry a "CVSS:x.y/" prefix. Downstream consumers
# (e.g. grype-db) expect the full CVSS vector string including this prefix.
_CVSS_TYPE_PREFIXES = {
"CVSS_V2": "CVSS:2.0/",
"CVSS_V3": "CVSS:3.0/",
"CVSS_V4": "CVSS:4.0/",
}


class Parser:
# an unauthenticated archive download (rather than a git clone) avoids any
# dependency on a git binary or the host's git configuration
_download_url_ = "https://github.com/bell-sw/osv-database/archive/refs/heads/master.tar.gz"
_archive_name_ = "osv-database.tar.gz"

def __init__(
self,
ws: Workspace,
fixdater: fixdate.Finder | None = None,
download_timeout: int = 125,
logger: logging.Logger | None = None,
):
if not fixdater:
fixdater = fixdate.default_finder(ws)
self.fixdater = fixdater
self.workspace = ws
self.download_timeout = download_timeout
self.urls = [self._download_url_]
if not logger:
logger = logging.getLogger(self.__class__.__name__)
self.logger = logger

def __enter__(self) -> Parser:
self.fixdater.__enter__()
return self

def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None:
self.fixdater.__exit__(exc_type, exc_val, exc_tb)

def _archive_path(self) -> str:
return os.path.join(self.workspace.input_path, self._archive_name_)

def _download(self) -> None:
self.logger.info(f"downloading vulnerability data from {self._download_url_}")
req = http.get(self._download_url_, self.logger, stream=True, timeout=self.download_timeout)
with open(self._archive_path(), "wb") as fp:
for chunk in req.iter_content(chunk_size=65536):
fp.write(chunk)

def _load(self) -> Generator[dict[str, Any]]:
self.logger.info("loading data from downloaded archive")

if not os.path.exists(self._archive_path()):
self.logger.warning("no downloaded archive to load")
return

# stream advisories straight out of the tarball rather than extracting
# ~16k small files to disk; members are never written out, so hostile
# member paths (traversal, symlinks) have nothing to act on. the github
# archive nests content under a "<repo>-<branch>/" top-level directory.
with tarfile.open(self._archive_path(), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "BELL-CVE" not in member.name.split("/"):
continue
if not member.name.endswith(".json"):
self.logger.debug(f"skipping non-JSON file: {member.name}")
continue
fh = tar.extractfile(member)
if fh is None:
continue
try:
yield orjson.loads(fh.read())
except orjson.JSONDecodeError:
# one malformed advisory in the upstream repo should not
# abort the whole provider run
self.logger.warning(f"skipping malformed advisory file: {member.name}")

def _normalize_severities(self, vuln_entry: dict[str, Any]) -> dict[str, Any]:
# Normalize CVSS severity vector strings so that each carries a
# "CVSS:x.y/" prefix appropriate to its type. If a score already has a
# "CVSS:" prefix (e.g. "CVSS:3.1/...") it is preserved as-is. Empty
# scores and entries without severities are left untouched. The input
# entry is not mutated; a copy is returned.
severities = vuln_entry.get("severity")
if not severities:
return vuln_entry

normalized = copy.deepcopy(vuln_entry)
for severity in normalized["severity"]:
score = severity.get("score", "")
if not score or score.startswith("CVSS:"):
continue
prefix = _CVSS_TYPE_PREFIXES.get(severity.get("type", ""))
if prefix:
severity["score"] = prefix + score

return normalized

def _normalize(self, vuln_entry: dict[str, Any]) -> tuple[str, str, dict[str, Any]]:
# We want to return the OSV record as it is (using OSV schema)
# We'll transform it into the Grype-specific vulnerability schema
# on grype-db
vuln_entry = self._normalize_severities(vuln_entry)
vuln_id = vuln_entry["id"]
# missing schema_version only matters for its major version: the provider
# maps any same-major value to its own pinned schema
vuln_schema = vuln_entry.get("schema_version", "1.0.0")

return vuln_id, vuln_schema, vuln_entry

def get(self) -> Generator[tuple[str, str, dict[str, Any]]]:
self._download()

self.fixdater.download()

for vuln_entry in self._load():
if not isinstance(vuln_entry, dict) or not vuln_entry.get("id"):
self.logger.warning("skipping advisory without an id")
elif "withdrawn" in vuln_entry:
self.logger.debug(f"skipping withdrawn entry: {vuln_entry['id']}")
else:
# annotate each affected range with first-observed fix dates
# (database_specific.anchore.fixes), which grype-db surfaces as
# fix availability
osv.patch_fix_date(vuln_entry, self.fixdater)
# Normalize the loaded data. Note: CVSS_V2 severities are kept
# deliberately — _normalize_severities gives their bare vectors
# the "CVSS:2.0/" prefix that downstream consumers (grype-db)
# require to parse them.
yield self._normalize(vuln_entry)
18 changes: 18 additions & 0 deletions tests/unit/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,24 @@ def test_config(monkeypatch) -> None:
skip_download: false
skip_newer_archive_check: false
user_agent: null
bellsoft:
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
user_agent: null
bitnami:
request_timeout: 125
runtime:
Expand Down
Loading
Loading